Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
/**
* Component: Audiobook Card
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { Button } from '@/components/ui/Button';
import { StatusBadge } from '@/components/requests/StatusBadge';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { useCreateRequest } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
interface AudiobookCardProps {
audiobook: Audiobook;
isRequested?: boolean;
requestStatus?: string;
onRequestSuccess?: () => void;
}
export function AudiobookCard({
audiobook,
isRequested = false,
requestStatus,
onRequestSuccess,
}: AudiobookCardProps) {
const { user } = useAuth();
const { createRequest, isLoading } = useCreateRequest();
const [showToast, setShowToast] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const handleRequest = async () => {
if (!user) {
setError('Please log in to request audiobooks');
return;
}
try {
await createRequest(audiobook);
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
onRequestSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create request');
setTimeout(() => setError(null), 5000);
}
};
const formatDuration = (minutes?: number) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${mins}m`;
};
return (
<>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
{/* Cover Art - Clickable */}
<div
className="relative aspect-[2/3] bg-gray-200 dark:bg-gray-700 cursor-pointer group"
onClick={() => setShowModal(true)}
>
{audiobook.coverArtUrl ? (
<Image
src={audiobook.coverArtUrl}
alt={`Cover art for ${audiobook.title}`}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
<svg
className="w-16 h-16"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
</div>
)}
{/* Hover overlay for click hint */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/90 dark:bg-gray-900/90 rounded-full p-3">
<svg className="w-6 h-6 text-gray-900 dark:text-gray-100" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
</div>
{/* Availability Badge */}
{audiobook.isAvailable && (
<div className="absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold px-2 py-1 rounded-md shadow-lg flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span>Available</span>
</div>
)}
</div>
{/* Content */}
<div className="p-4 space-y-2">
{/* Title - Clickable */}
<h3
className="font-semibold text-gray-900 dark:text-gray-100 line-clamp-2 min-h-[3rem] cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
onClick={() => setShowModal(true)}
>
{audiobook.title}
</h3>
{/* Author */}
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-1">
By {audiobook.author}
</p>
{/* Narrator */}
{audiobook.narrator && (
<p className="text-xs text-gray-500 dark:text-gray-500 line-clamp-1">
Narrated by {audiobook.narrator}
</p>
)}
{/* Metadata Row */}
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
{audiobook.rating && (
<div className="flex items-center gap-1">
<svg className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span>{audiobook.rating.toFixed(1)}</span>
</div>
)}
{audiobook.durationMinutes && (
<span>{formatDuration(audiobook.durationMinutes)}</span>
)}
</div>
{/* Status or Action */}
<div className="pt-2">
{(() => {
// Check if book is already available in Plex or completed/available status
if (audiobook.isAvailable || audiobook.requestStatus === 'completed') {
return (
<div className="w-full py-2 px-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md text-center">
<span className="text-sm font-medium text-green-700 dark:text-green-400">
In Your Library
</span>
</div>
);
}
// Check if book is requested and in progress (non-re-requestable statuses)
const inProgressStatuses = ['pending', 'awaiting_search', 'searching', 'downloading', 'processing', 'awaiting_import'];
if (audiobook.isRequested && audiobook.requestStatus && inProgressStatuses.includes(audiobook.requestStatus)) {
// Show who requested it
const buttonText = audiobook.requestedByUsername
? `Requested by ${audiobook.requestedByUsername}`
: 'Requested';
return (
<Button
onClick={() => {}}
disabled={true}
variant="primary"
size="md"
className="w-full cursor-not-allowed opacity-75"
>
{buttonText}
</Button>
);
}
// For failed/warn/cancelled or no request - show Request button
return (
<Button
onClick={handleRequest}
loading={isLoading}
disabled={!user}
variant="primary"
size="md"
className="w-full"
>
{!user ? 'Login to Request' : 'Request'}
</Button>
);
})()}
</div>
{/* Error Message */}
{error && (
<p className="text-xs text-red-600 dark:text-red-400 text-center">{error}</p>
)}
{/* Success Toast */}
{showToast && (
<p className="text-xs text-green-600 dark:text-green-400 text-center font-medium">
Request created successfully!
</p>
)}
</div>
</div>
{/* Details Modal */}
<AudiobookDetailsModal
asin={audiobook.asin}
isOpen={showModal}
onClose={() => setShowModal(false)}
onRequestSuccess={onRequestSuccess}
isRequested={audiobook.isRequested}
requestStatus={audiobook.requestStatus}
isAvailable={audiobook.isAvailable}
requestedByUsername={audiobook.requestedByUsername}
/>
</>
);
}
@@ -0,0 +1,411 @@
/**
* Component: Audiobook Details Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/Button';
import { StatusBadge } from '@/components/requests/StatusBadge';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
interface AudiobookDetailsModalProps {
asin: string;
isOpen: boolean;
onClose: () => void;
onRequestSuccess?: () => void;
isRequested?: boolean;
requestStatus?: string | null;
isAvailable?: boolean;
requestedByUsername?: string | null;
}
export function AudiobookDetailsModal({
asin,
isOpen,
onClose,
onRequestSuccess,
isRequested = false,
requestStatus = null,
isAvailable = false,
requestedByUsername = null,
}: AudiobookDetailsModalProps) {
const { user } = useAuth();
const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { createRequest, isLoading: isRequesting } = useCreateRequest();
const [showToast, setShowToast] = useState(false);
const [requestError, setRequestError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
const handleRequest = async () => {
if (!user || !audiobook) {
setRequestError('Please log in to request audiobooks');
return;
}
try {
await createRequest(audiobook);
setShowToast(true);
setTimeout(() => {
setShowToast(false);
onClose();
}, 2000);
onRequestSuccess?.();
} catch (err) {
setRequestError(err instanceof Error ? err.message : 'Failed to create request');
setTimeout(() => setRequestError(null), 5000);
}
};
const formatDuration = (minutes?: number) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours} hr ${mins} min`;
};
const formatDate = (dateString?: string) => {
if (!dateString) return null;
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
} catch {
return dateString;
}
};
if (!isOpen || !mounted) return null;
const modalContent = (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-2 sm:p-4 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative w-full max-w-4xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-900 rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
aria-label="Close modal"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)}
{/* Error State */}
{error && !isLoading && (
<div className="p-8">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 text-center">
<p className="text-red-800 dark:text-red-200 font-medium">
Failed to load audiobook details
</p>
<p className="text-red-700 dark:text-red-300 text-sm mt-2">
Please try again later
</p>
</div>
</div>
)}
{/* Content */}
{audiobook && !isLoading && (
<div className="p-4 sm:p-6 md:p-8 space-y-4 sm:space-y-6">
{/* Header Section */}
<div className="flex flex-col md:flex-row gap-4 sm:gap-6">
{/* Cover Art */}
<div className="flex-shrink-0 mx-auto md:mx-0">
<div className="relative w-32 sm:w-40 md:w-48 aspect-[2/3] bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden shadow-lg">
{audiobook.coverArtUrl ? (
<Image
src={audiobook.coverArtUrl}
alt={`Cover art for ${audiobook.title}`}
fill
className="object-cover"
sizes="192px"
priority
/>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
<svg
className="w-16 h-16"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
</div>
)}
</div>
</div>
{/* Metadata */}
<div className="flex-1 space-y-3 sm:space-y-4 text-center md:text-left">
{/* Title */}
<div>
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100">
{audiobook.title}
</h2>
</div>
{/* Author */}
<div className="space-y-1">
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400">By</p>
<p className="text-base sm:text-lg text-gray-700 dark:text-gray-300 font-medium">
{audiobook.author}
</p>
</div>
{/* Narrator */}
{audiobook.narrator && (
<div className="space-y-1">
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400">Narrated by</p>
<p className="text-base sm:text-lg text-gray-700 dark:text-gray-300">
{audiobook.narrator}
</p>
</div>
)}
{/* Metadata Grid */}
<div className="grid grid-cols-2 gap-4 pt-2">
{/* Rating */}
{audiobook.rating && (
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Rating</p>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{[...Array(5)].map((_, i) => (
<svg
key={i}
className={`w-5 h-5 ${
i < Math.floor(Number(audiobook.rating))
? 'text-yellow-400 fill-current'
: 'text-gray-300 dark:text-gray-600'
}`}
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
<span className="text-gray-700 dark:text-gray-300 font-medium">
{Number(audiobook.rating).toFixed(1)}
</span>
</div>
</div>
)}
{/* Duration */}
{audiobook.durationMinutes && (
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Length</p>
<p className="text-gray-700 dark:text-gray-300 font-medium">
{formatDuration(audiobook.durationMinutes)}
</p>
</div>
)}
{/* Release Date */}
{audiobook.releaseDate && (
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Release Date</p>
<p className="text-gray-700 dark:text-gray-300">
{formatDate(audiobook.releaseDate)}
</p>
</div>
)}
{/* Availability Status */}
{isAvailable && (
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Status</p>
<div className="inline-flex items-center gap-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 text-sm font-semibold px-3 py-1 rounded-full">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>In Your Library</span>
</div>
</div>
)}
</div>
{/* Genres */}
{audiobook.genres && audiobook.genres.length > 0 && (
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">Genres</p>
<div className="flex flex-wrap gap-2">
{audiobook.genres.map((genre: string) => (
<span
key={genre}
className="px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-sm rounded-full"
>
{genre}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* Description */}
{audiobook.description && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 sm:pt-6">
<h3 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2 sm:mb-3">
Publisher's Summary
</h3>
<div className="text-sm sm:text-base text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
{audiobook.description}
</div>
</div>
)}
{/* Action Buttons */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 sm:pt-6 flex flex-col sm:flex-row gap-2 sm:gap-3">
{(() => {
// Use props from card instead of fetched audiobook data for request status
// Check if book is already available in library or completed status
if (isAvailable || requestStatus === 'completed') {
return (
<div className="flex-1">
<div className="w-full py-3 px-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-lg text-center">
<span className="text-base font-semibold text-green-700 dark:text-green-400">
Available in Your Library
</span>
</div>
</div>
);
}
// Check if book is requested and in progress
const inProgressStatuses = [
'pending',
'awaiting_search',
'searching',
'downloading',
'processing',
'awaiting_import',
];
if (
isRequested &&
requestStatus &&
inProgressStatuses.includes(requestStatus)
) {
// Show who requested it
const buttonText = requestedByUsername
? `Requested by ${requestedByUsername}`
: 'Already Requested';
return (
<div className="flex-1">
<Button
onClick={() => {}}
disabled={true}
variant="primary"
size="lg"
className="w-full cursor-not-allowed opacity-75"
>
{buttonText}
</Button>
</div>
);
}
// For failed/warn/cancelled or no request - show Request button
return (
<div className="flex-1">
<Button
onClick={handleRequest}
loading={isRequesting}
disabled={!user}
variant="primary"
size="lg"
className="w-full"
>
{!user ? 'Login to Request' : 'Request Audiobook'}
</Button>
</div>
);
})()}
<Button onClick={onClose} variant="outline" size="lg">
Close
</Button>
</div>
{/* Error Message */}
{requestError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-200 text-center">{requestError}</p>
</div>
)}
{/* Success Toast */}
{showToast && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<p className="text-green-800 dark:text-green-200 text-center font-medium">
Request created successfully!
</p>
</div>
)}
</div>
)}
</div>
</div>
);
return createPortal(modalContent, document.body);
}
@@ -0,0 +1,92 @@
/**
* Component: Audiobook Grid
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
import { AudiobookCard } from './AudiobookCard';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
interface AudiobookGridProps {
audiobooks: Audiobook[];
isLoading?: boolean;
emptyMessage?: string;
onRequestSuccess?: () => void;
}
export function AudiobookGrid({
audiobooks,
isLoading = false,
emptyMessage = 'No audiobooks found',
onRequestSuccess,
}: AudiobookGridProps) {
if (isLoading) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 sm:gap-4 md:gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
);
}
if (audiobooks.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<svg
className="w-16 h-16 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
<p className="text-gray-600 dark:text-gray-400">{emptyMessage}</p>
</div>
);
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 sm:gap-4 md:gap-6">
{audiobooks.map((audiobook) => (
<AudiobookCard
key={audiobook.asin}
audiobook={audiobook}
onRequestSuccess={onRequestSuccess}
/>
))}
</div>
);
}
function SkeletonCard() {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden animate-pulse">
{/* Cover Art Skeleton */}
<div className="aspect-[2/3] bg-gray-200 dark:bg-gray-700" />
{/* Content Skeleton */}
<div className="p-4 space-y-3">
{/* Title */}
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
{/* Author */}
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3" />
{/* Metadata */}
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
{/* Button */}
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Component: Protected Route Wrapper
* Documentation: documentation/frontend/routing-auth.md
*/
'use client';
import { ReactNode, useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
interface ProtectedRouteProps {
children: ReactNode;
requireAdmin?: boolean;
}
export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
const router = useRouter();
const pathname = usePathname();
const { user, isLoading } = useAuth();
useEffect(() => {
// Wait for auth to finish loading
if (isLoading) return;
// Not authenticated - redirect to login with return URL
if (!user) {
const redirectUrl = encodeURIComponent(pathname);
router.push(`/login?redirect=${redirectUrl}`);
return;
}
// Admin required but user is not admin - redirect to homepage
if (requireAdmin && user.role !== 'admin') {
router.push('/');
return;
}
}, [user, isLoading, requireAdmin, router, pathname]);
// Show loading state while checking authentication
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}
// Not authenticated or wrong role - don't render children
// (redirect will happen in useEffect)
if (!user || (requireAdmin && user.role !== 'admin')) {
return null;
}
// User is authenticated and authorized - render children
return <>{children}</>;
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Component: BookDate Loading Screen
* Documentation: documentation/features/bookdate-prd.md
*/
'use client';
import { Header } from '@/components/layout/Header';
export function LoadingScreen() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header />
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-80px)] px-4">
{/* Animated book cards */}
<div className="relative w-64 h-96 mb-8">
{/* Card 1 */}
<div
className="absolute inset-0 rounded-2xl shadow-2xl animate-pulse"
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
animationDelay: '0s',
}}
/>
{/* Card 2 */}
<div
className="absolute inset-0 rounded-2xl shadow-2xl animate-bounce"
style={{
background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
animationDelay: '0.2s',
opacity: 0.8,
}}
/>
{/* Card 3 */}
<div
className="absolute inset-0 rounded-2xl shadow-2xl animate-ping"
style={{
background: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
animationDelay: '0.4s',
opacity: 0.6,
}}
/>
{/* Book icon */}
<div className="absolute inset-0 flex items-center justify-center z-10">
<span className="text-6xl animate-pulse" style={{ animationDuration: '2s' }}>
📚
</span>
</div>
</div>
{/* Loading text */}
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Finding your next great listen...
</h2>
<p className="text-gray-600 dark:text-gray-400">
Our AI is analyzing your preferences
</p>
</div>
{/* Loading dots */}
<div className="flex gap-2 mt-6">
<div
className="w-3 h-3 bg-blue-500 rounded-full animate-bounce"
style={{ animationDelay: '0s' }}
/>
<div
className="w-3 h-3 bg-blue-500 rounded-full animate-bounce"
style={{ animationDelay: '0.1s' }}
/>
<div
className="w-3 h-3 bg-blue-500 rounded-full animate-bounce"
style={{ animationDelay: '0.2s' }}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,248 @@
/**
* Component: BookDate Recommendation Card
* Documentation: documentation/features/bookdate-prd.md
*/
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { useSwipeable } from 'react-swipeable';
interface RecommendationCardProps {
recommendation: any;
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
}
export function RecommendationCard({
recommendation,
onSwipe,
}: RecommendationCardProps) {
const [showToast, setShowToast] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const handleSwipeRight = () => {
setShowToast(true);
};
const handleToastAction = (action: 'request' | 'known' | 'cancel') => {
setShowToast(false);
if (action === 'request') {
onSwipe('right', false);
} else if (action === 'known') {
onSwipe('right', true);
}
};
const swipeHandlers = useSwipeable({
onSwiping: (eventData) => {
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
},
onSwiped: (eventData) => {
// Check final position when user releases - must be at 100px threshold
const finalX = eventData.deltaX;
const finalY = eventData.deltaY;
const threshold = 100;
// Determine which direction had the strongest swipe at release
if (Math.abs(finalX) > Math.abs(finalY)) {
// Horizontal swipe
if (finalX > threshold) {
handleSwipeRight();
} else if (finalX < -threshold) {
onSwipe('left');
}
} else {
// Vertical swipe
if (finalY < -threshold) {
onSwipe('up');
}
}
// Reset drag offset
setDragOffset({ x: 0, y: 0 });
},
trackMouse: true,
preventScrollOnSwipe: true,
// Don't use built-in delta threshold - we'll check manually in onSwiped
delta: 0,
});
const getOverlayOpacity = (threshold: number, value: number) => {
return Math.min(Math.abs(value) / threshold, 1);
};
// Determine which overlay to show based on dominant direction
const getDominantDirection = () => {
const absX = Math.abs(dragOffset.x);
const absY = Math.abs(dragOffset.y);
if (absX < 50 && absY < 50) return null; // No overlay if not dragged enough
if (absX > absY) {
return dragOffset.x > 0 ? 'right' : 'left';
} else {
return dragOffset.y < 0 ? 'up' : null; // Only up swipe for vertical
}
};
const dominantDirection = getDominantDirection();
return (
<>
<div
{...swipeHandlers}
className="relative w-full max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden select-none max-h-[80vh] md:max-h-[85vh] flex flex-col"
style={{
transform: `translate(${dragOffset.x}px, ${dragOffset.y}px) rotate(${dragOffset.x * 0.05}deg)`,
transition: dragOffset.x === 0 && dragOffset.y === 0 ? 'transform 0.3s ease-out' : 'none',
}}
>
{/* Drag overlay indicators - show only dominant direction */}
{dominantDirection === 'right' && (
<div
className="absolute inset-0 bg-green-500 flex items-center justify-center pointer-events-none z-10"
style={{ opacity: getOverlayOpacity(100, dragOffset.x) * 0.4 }}
>
<div className="bg-white rounded-full p-6 shadow-lg flex flex-col items-center gap-2">
<span className="text-6xl"></span>
<span className="text-xl font-bold text-green-600">Request</span>
</div>
</div>
)}
{dominantDirection === 'left' && (
<div
className="absolute inset-0 bg-red-500 flex items-center justify-center pointer-events-none z-10"
style={{ opacity: getOverlayOpacity(100, dragOffset.x) * 0.4 }}
>
<div className="bg-white rounded-full p-6 shadow-lg flex flex-col items-center gap-2">
<span className="text-6xl"></span>
<span className="text-xl font-bold text-red-600">Dislike</span>
</div>
</div>
)}
{dominantDirection === 'up' && (
<div
className="absolute inset-0 bg-blue-500 flex items-center justify-center pointer-events-none z-10"
style={{ opacity: getOverlayOpacity(100, dragOffset.y) * 0.4 }}
>
<div className="bg-white rounded-full p-6 shadow-lg flex flex-col items-center gap-2">
<span className="text-6xl"></span>
<span className="text-xl font-bold text-blue-600">Dismiss</span>
</div>
</div>
)}
{/* Cover image - smaller on mobile to fit all content */}
<div className="w-full relative bg-gray-200 dark:bg-gray-700 flex-shrink-0" style={{ maxHeight: 'min(25vh, 300px)' }}>
{recommendation.coverUrl ? (
<Image
src={recommendation.coverUrl}
alt={recommendation.title}
width={400}
height={400}
className="object-contain w-full h-auto"
style={{ maxHeight: 'min(25vh, 300px)' }}
unoptimized
/>
) : (
<div className="w-full h-48 flex items-center justify-center">
<span className="text-6xl">📚</span>
</div>
)}
</div>
{/* Book info - reduced padding on mobile */}
<div className="p-4 md:p-6 overflow-y-auto flex-1">
<h3 className="text-xl md:text-2xl font-bold mb-2 text-gray-900 dark:text-white line-clamp-2">
{recommendation.title}
</h3>
<p className="text-base md:text-lg text-gray-600 dark:text-gray-400 mb-1">
{recommendation.author}
</p>
{recommendation.narrator && (
<p className="text-xs md:text-sm text-gray-500 dark:text-gray-500 mb-2">
Narrated by {recommendation.narrator}
</p>
)}
{recommendation.rating && (
<div className="flex items-center mb-2">
<span className="text-yellow-500 text-lg md:text-xl"></span>
<span className="ml-2 text-base md:text-lg font-semibold text-gray-700 dark:text-gray-300">
{Number(recommendation.rating).toFixed(1)}
</span>
</div>
)}
{recommendation.description && (
<p className="text-xs md:text-sm text-gray-700 dark:text-gray-300 line-clamp-3 md:line-clamp-4 mb-2">
{recommendation.description}
</p>
)}
{recommendation.aiReason && (
<div className="mt-2 md:mt-4 p-2 md:p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-xs text-blue-700 dark:text-blue-300 italic line-clamp-3">
💡 {recommendation.aiReason}
</p>
</div>
)}
</div>
{/* Desktop buttons */}
<div className="hidden md:flex justify-center gap-4 p-6 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => onSwipe('left')}
className="px-6 py-3 bg-red-500 hover:bg-red-600 text-white rounded-full font-medium transition-colors shadow-lg"
>
Not Interested
</button>
<button
onClick={() => onSwipe('up')}
className="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-full font-medium transition-colors shadow-lg"
>
Dismiss
</button>
<button
onClick={handleSwipeRight}
className="px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-full font-medium transition-colors shadow-lg"
>
Request
</button>
</div>
</div>
{/* Confirmation Toast */}
{showToast && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full shadow-2xl">
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
Request "{recommendation.title}"?
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Do you want to request this audiobook, or have you already read/listened to and enjoyed it?
</p>
<div className="flex gap-3">
<button
onClick={() => handleToastAction('cancel')}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
onClick={() => handleToastAction('known')}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Mark as Liked
</button>
<button
onClick={() => handleToastAction('request')}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors"
>
Request
</button>
</div>
</div>
</div>
)}
</>
);
}
+257
View File
@@ -0,0 +1,257 @@
/**
* Component: BookDate Settings Widget
* Documentation: documentation/features/bookdate.md
*/
'use client';
import { useState, useEffect } from 'react';
interface SettingsWidgetProps {
isOpen: boolean;
onClose: () => void;
isOnboarding?: boolean; // If true, this is first-time onboarding
onOnboardingComplete?: () => void; // Called when onboarding is saved
}
export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboardingComplete }: SettingsWidgetProps) {
const [libraryScope, setLibraryScope] = useState<'full' | 'rated'>('full');
const [customPrompt, setCustomPrompt] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Load current preferences
useEffect(() => {
if (isOpen) {
loadPreferences();
}
}, [isOpen]);
const loadPreferences = async () => {
setLoading(true);
setError(null);
try {
const accessToken = localStorage.getItem('accessToken');
const response = await fetch('/api/bookdate/preferences', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to load preferences');
}
const data = await response.json();
setLibraryScope(data.libraryScope || 'full');
setCustomPrompt(data.customPrompt || '');
} catch (error: any) {
console.error('Load preferences error:', error);
setError(error.message || 'Failed to load preferences');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccessMessage(null);
try {
const accessToken = localStorage.getItem('accessToken');
const trimmedPrompt = customPrompt.trim();
const response = await fetch('/api/bookdate/preferences', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({
libraryScope,
customPrompt: trimmedPrompt || null, // Send null if empty
onboardingComplete: isOnboarding ? true : undefined,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to save preferences');
}
setSuccessMessage('Preferences saved successfully!');
// If this is onboarding, call the completion callback after a short delay
if (isOnboarding && onOnboardingComplete) {
setTimeout(() => {
onOnboardingComplete();
onClose();
}, 500);
} else {
// Clear success message after 3 seconds for normal saves
setTimeout(() => {
setSuccessMessage(null);
}, 3000);
}
} catch (error: any) {
console.error('Save preferences error:', error);
setError(error.message || 'Failed to save preferences');
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40 transition-opacity"
onClick={onClose}
/>
{/* Settings Panel */}
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-2xl z-50 max-h-[90vh] overflow-y-auto">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{isOnboarding ? 'Welcome to BookDate!' : 'BookDate Preferences'}
</h2>
{isOnboarding && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Customize your recommendations before we begin
</p>
)}
</div>
{!isOnboarding && (
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-2xl leading-none"
>
×
</button>
)}
</div>
{/* Loading State */}
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">
{error}
</div>
)}
{/* Success Message */}
{successMessage && (
<div className="mb-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-green-700 dark:text-green-300 text-sm">
{successMessage}
</div>
)}
{/* Library Scope */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Library Scope
</label>
<div className="space-y-3">
<label className={`flex items-start p-4 border-2 rounded-lg cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50 ${libraryScope === 'full' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-600'}`}>
<input
type="radio"
name="libraryScope"
value="full"
checked={libraryScope === 'full'}
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated')}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500"
/>
<div className="ml-3 flex-1">
<div className="font-medium text-gray-900 dark:text-white">
Full Library
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Get recommendations based on your entire library
</div>
</div>
</label>
<label className={`flex items-start p-4 border-2 rounded-lg cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50 ${libraryScope === 'rated' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-600'}`}>
<input
type="radio"
name="libraryScope"
value="rated"
checked={libraryScope === 'rated'}
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated')}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500"
/>
<div className="ml-3 flex-1">
<div className="font-medium text-gray-900 dark:text-white">
Rated Books Only
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Only consider books you've rated for recommendations
</div>
</div>
</label>
</div>
</div>
{/* Custom Prompt */}
<div className="mb-6">
<label htmlFor="customPrompt" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Custom Prompt Modifier
<span className="text-gray-500 dark:text-gray-400 font-normal ml-2">
(Optional)
</span>
</label>
<textarea
id="customPrompt"
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
maxLength={1000}
rows={4}
placeholder="e.g., I prefer mysteries set in historical periods, or narrators with British accents..."
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
/>
<div className="mt-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>Add preferences to guide recommendations</span>
<span>{customPrompt.length}/1000</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4">
<button
onClick={handleSave}
disabled={saving}
className="flex-1 px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium transition-colors"
>
{saving ? 'Saving...' : isOnboarding ? "Let's Go!" : 'Save Preferences'}
</button>
{!isOnboarding && (
<button
onClick={onClose}
className="px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
)}
</div>
</>
)}
</div>
</div>
</>
);
}
+258
View File
@@ -0,0 +1,258 @@
/**
* Component: Header Navigation
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/Button';
export function Header() {
const { user, logout } = useAuth();
const [showUserMenu, setShowUserMenu] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false);
const [showBookDate, setShowBookDate] = useState(false);
// Check if BookDate is configured
useEffect(() => {
async function checkBookDate() {
if (!user) {
setShowBookDate(false);
return;
}
try {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
setShowBookDate(false);
return;
}
const response = await fetch('/api/bookdate/config', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const data = await response.json();
// Show BookDate to any user with verified and enabled configuration
setShowBookDate(
data.config &&
data.config.isVerified &&
data.config.isEnabled
);
} catch (error) {
console.error('Failed to check BookDate config:', error);
setShowBookDate(false);
}
}
checkBookDate();
}, [user]);
const handleLogin = async () => {
try {
const response = await fetch('/api/auth/plex/login', { method: 'POST' });
const data = await response.json();
if (data.success) {
// Open Plex OAuth in popup
window.open(data.authUrl, 'plex-auth', 'width=600,height=700');
}
} catch (error) {
console.error('Login failed:', error);
}
};
return (
<header className="bg-white dark:bg-gray-800 shadow-sm sticky top-0 z-40">
<div className="container mx-auto px-4 py-3 md:py-4 max-w-7xl">
<div className="flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center gap-2">
<img
src="/rmab_32x32.png"
alt="ReadMeABook Logo"
className="w-8 h-8"
/>
<span className="text-lg md:text-xl font-bold text-gray-900 dark:text-gray-100">
ReadMeABook
</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-6">
<Link
href="/"
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
Home
</Link>
<Link
href="/search"
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
Search
</Link>
{showBookDate && (
<Link
href="/bookdate"
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
BookDate
</Link>
)}
{user && (
<Link
href="/requests"
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
My Requests
</Link>
)}
{user?.role === 'admin' && (
<Link
href="/admin"
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
Admin
</Link>
)}
</nav>
{/* Mobile Menu Button & User Menu */}
<div className="flex items-center gap-2 md:gap-4">
{/* Search Button (visible on mobile) */}
<Link
href="/search"
className="md:hidden p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
aria-label="Search"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</Link>
{/* Mobile Menu Button */}
<button
onClick={() => setShowMobileMenu(!showMobileMenu)}
className="md:hidden p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
aria-label="Toggle menu"
>
{showMobileMenu ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
{user ? (
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.username}
className="w-8 h-8 rounded-full"
/>
) : (
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white font-medium">
{user.username.charAt(0).toUpperCase()}
</div>
)}
<span className="hidden md:inline text-gray-700 dark:text-gray-300">
{user.username}
</span>
</button>
{showUserMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg py-1 z-50">
<Link
href="/profile"
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setShowUserMenu(false)}
>
Profile
</Link>
<button
onClick={() => {
logout();
setShowUserMenu(false);
}}
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Logout
</button>
</div>
)}
</div>
) : (
<Button onClick={handleLogin} variant="primary" size="sm">
Login with Plex
</Button>
)}
</div>
</div>
{/* Mobile Navigation Menu */}
{showMobileMenu && (
<div className="md:hidden border-t border-gray-200 dark:border-gray-700 mt-3 pt-3">
<nav className="flex flex-col space-y-2">
<Link
href="/"
onClick={() => setShowMobileMenu(false)}
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
>
Home
</Link>
<Link
href="/search"
onClick={() => setShowMobileMenu(false)}
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
>
Search
</Link>
{showBookDate && (
<Link
href="/bookdate"
onClick={() => setShowMobileMenu(false)}
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
>
BookDate
</Link>
)}
{user && (
<Link
href="/requests"
onClick={() => setShowMobileMenu(false)}
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
>
My Requests
</Link>
)}
{user?.role === 'admin' && (
<Link
href="/admin"
onClick={() => setShowMobileMenu(false)}
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
>
Admin
</Link>
)}
</nav>
</div>
)}
</div>
</header>
);
}
@@ -0,0 +1,241 @@
/**
* Component: Interactive Torrent Search Modal
* Documentation: documentation/phase3/prowlarr.md
*/
'use client';
import React, { useState } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { useInteractiveSearch, useSelectTorrent } from '@/lib/hooks/useRequests';
interface InteractiveTorrentSearchModalProps {
isOpen: boolean;
onClose: () => void;
requestId: string;
audiobook: {
title: string;
author: string;
};
}
export function InteractiveTorrentSearchModal({
isOpen,
onClose,
requestId,
audiobook,
}: InteractiveTorrentSearchModalProps) {
const { searchTorrents, isLoading: isSearching, error: searchError } = useInteractiveSearch();
const { selectTorrent, isLoading: isDownloading, error: downloadError } = useSelectTorrent();
const [results, setResults] = useState<(TorrentResult & { rank: number; qualityScore?: number })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
const error = searchError || downloadError;
// Perform search when modal opens
React.useEffect(() => {
if (isOpen && results.length === 0) {
performSearch();
}
}, [isOpen]);
const performSearch = async () => {
try {
const data = await searchTorrents(requestId);
setResults(data || []);
} catch (err) {
// Error already handled by hook
console.error('Search failed:', err);
}
};
const handleDownloadClick = (torrent: TorrentResult) => {
setConfirmTorrent(torrent);
};
const handleConfirmDownload = async () => {
if (!confirmTorrent) return;
try {
await selectTorrent(requestId, confirmTorrent);
// Close modals on success
setConfirmTorrent(null);
onClose();
// Request list will auto-refresh via SWR
} catch (err) {
// Error already handled by hook
console.error('Failed to download torrent:', err);
setConfirmTorrent(null);
}
};
const formatSize = (bytes: number) => {
const gb = bytes / (1024 ** 3);
const mb = bytes / (1024 ** 2);
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
};
const getQualityBadgeColor = (score: number) => {
if (score >= 90) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
if (score >= 70) return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
if (score >= 50) return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
};
return (
<>
<Modal isOpen={isOpen} onClose={onClose} title="Select Torrent" size="full">
<div className="space-y-4">
{/* Audiobook info */}
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{audiobook.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">By {audiobook.author}</p>
</div>
{/* Error message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Loading state */}
{isSearching && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-gray-300 border-t-blue-600 rounded-full"></div>
<span className="ml-3 text-gray-600 dark:text-gray-400">Searching for torrents...</span>
</div>
)}
{/* No results */}
{!isSearching && results.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No torrents found</p>
<Button onClick={performSearch} variant="outline" className="mt-4">
Try Again
</Button>
</div>
)}
{/* Results table */}
{!isSearching && results.length > 0 && (
<div className="overflow-x-auto -mx-6">
<div className="inline-block min-w-full align-middle px-6">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
#
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Title
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden sm:table-cell">
Size
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Score
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell">
Seeds
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell">
Indexer
</th>
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Action
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{results.map((result) => (
<tr key={result.guid} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-2 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
{result.rank}
</td>
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100">
<div className="max-w-xs lg:max-w-md truncate" title={result.title}>
{result.title}
</div>
<div className="flex gap-2 mt-1 flex-wrap">
{result.format && (
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded">
{result.format}
</span>
)}
<span className="sm:hidden inline-block px-2 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded">
{formatSize(result.size)}
</span>
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
{result.seeders} seeds
</span>
</div>
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden sm:table-cell">
{formatSize(result.size)}
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm">
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(result.qualityScore || 0)}`}>
{result.qualityScore || 0}
</span>
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden md:table-cell">
<span className="flex items-center gap-1">
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
</svg>
{result.seeders}
</span>
</td>
<td className="px-2 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 hidden lg:table-cell">
{result.indexer}
</td>
<td className="px-2 py-3 whitespace-nowrap text-right text-sm">
<Button
onClick={() => handleDownloadClick(result)}
disabled={isDownloading}
size="sm"
variant="primary"
>
Download
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Footer with result count */}
{!isSearching && results.length > 0 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
Found {results.length} torrent{results.length !== 1 ? 's' : ''}
</p>
<Button onClick={performSearch} variant="outline" size="sm">
Refresh Results
</Button>
</div>
)}
</div>
</Modal>
{/* Confirmation Modal */}
<ConfirmModal
isOpen={!!confirmTorrent}
onClose={() => setConfirmTorrent(null)}
onConfirm={handleConfirmDownload}
title="Download Torrent"
message={`Download "${confirmTorrent?.title}"?`}
confirmText="Download"
isLoading={isDownloading}
variant="primary"
/>
</>
);
}
+255
View File
@@ -0,0 +1,255 @@
/**
* Component: Request Card
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
import Image from 'next/image';
import { StatusBadge } from './StatusBadge';
import { Button } from '@/components/ui/Button';
import { useCancelRequest, useManualSearch } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
interface RequestCardProps {
request: {
id: string;
status: string;
progress: number;
errorMessage?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
audiobook: {
id: string;
title: string;
author: string;
coverArtUrl?: string;
};
};
showActions?: boolean;
}
export function RequestCard({ request, showActions = true }: RequestCardProps) {
const { cancelRequest, isLoading } = useCancelRequest();
const { triggerManualSearch, isLoading: isManualSearching } = useManualSearch();
const [showError, setShowError] = React.useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false);
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed';
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
const handleCancel = async () => {
if (window.confirm('Are you sure you want to cancel this request?')) {
try {
await cancelRequest(request.id);
} catch (error) {
console.error('Failed to cancel request:', error);
}
}
};
const handleManualSearch = async () => {
try {
await triggerManualSearch(request.id);
// Request list will auto-refresh via SWR
} catch (error) {
console.error('Failed to trigger manual search:', error);
alert(error instanceof Error ? error.message : 'Failed to trigger manual search');
}
};
const handleInteractiveSearch = () => {
setShowInteractiveSearch(true);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
<div className="flex gap-3 sm:gap-4 p-3 sm:p-4">
{/* Cover Art */}
<div className="flex-shrink-0">
<div className="relative w-16 h-24 sm:w-24 sm:h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700">
{request.audiobook.coverArtUrl ? (
<Image
src={request.audiobook.coverArtUrl}
alt={request.audiobook.title}
fill
className="object-cover"
sizes="96px"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<svg
className="w-12 h-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
</div>
)}
</div>
</div>
{/* Request Info */}
<div className="flex-1 min-w-0 space-y-1.5 sm:space-y-2">
{/* Title and Author */}
<div>
<h3 className="text-sm sm:text-base md:text-lg font-semibold text-gray-900 dark:text-gray-100 line-clamp-2">
{request.audiobook.title}
</h3>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400 truncate">
By {request.audiobook.author}
</p>
</div>
{/* Status Badge */}
<div className="flex items-center gap-2">
<StatusBadge status={request.status} progress={request.progress} />
{isActive && request.progress > 0 && (
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<div className="animate-pulse w-2 h-2 bg-blue-500 rounded-full"></div>
<span>Active</span>
</div>
)}
{isActive && request.progress === 0 && (
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<div className="animate-spin w-3 h-3 border-2 border-gray-300 border-t-blue-500 rounded-full"></div>
<span>Setting up...</span>
</div>
)}
</div>
{/* Progress Bar (for downloading/processing) */}
{isActive && request.progress > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400">
<span>Progress</span>
<span>{request.progress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
request.status === 'downloading' ? 'bg-purple-600' : 'bg-orange-600'
)}
style={{ width: `${request.progress}%` }}
/>
</div>
</div>
)}
{/* Error Message */}
{isFailed && request.errorMessage && (
<div className="space-y-1">
<button
onClick={() => setShowError(!showError)}
className="text-xs text-red-600 dark:text-red-400 hover:underline flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={showError ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'}
/>
</svg>
{showError ? 'Hide error' : 'Show error'}
</button>
{showError && (
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-2 rounded">
{request.errorMessage}
</div>
)}
</div>
)}
{/* Timestamps and Actions */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-500 dark:text-gray-500">
{request.completedAt
? `Completed ${formatDate(request.completedAt)}`
: `Requested ${formatDate(request.createdAt)}`}
</div>
{/* Action Buttons */}
{showActions && (
<div className="flex flex-wrap gap-2">
{canSearch && (
<>
<Button
onClick={handleManualSearch}
loading={isManualSearching}
variant="outline"
size="sm"
className="text-xs sm:text-sm"
>
Manual Search
</Button>
<Button
onClick={handleInteractiveSearch}
variant="primary"
size="sm"
className="text-xs sm:text-sm"
>
Interactive Search
</Button>
</>
)}
{canCancel && (
<Button
onClick={handleCancel}
loading={isLoading}
variant="outline"
size="sm"
className="text-xs sm:text-sm text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Cancel
</Button>
)}
</div>
)}
</div>
</div>
</div>
{/* Interactive Search Modal */}
<InteractiveTorrentSearchModal
isOpen={showInteractiveSearch}
onClose={() => setShowInteractiveSearch(false)}
requestId={request.id}
audiobook={{
title: request.audiobook.title,
author: request.audiobook.author,
}}
/>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Component: Status Badge
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
import { cn } from '@/lib/utils/cn';
interface StatusBadgeProps {
status: string;
progress?: number;
className?: string;
}
export function StatusBadge({ status, progress, className }: StatusBadgeProps) {
const statusConfig: Record<string, { label: string; color: string }> = {
pending: {
label: 'Pending',
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
},
awaiting_search: {
label: 'Awaiting Search',
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
},
searching: {
label: 'Searching...',
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
},
downloading: {
label: progress !== undefined && progress === 0 ? 'Initializing...' : 'Downloading',
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
},
downloaded: {
label: 'Downloaded',
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
},
processing: {
label: 'Processing',
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
},
awaiting_import: {
label: 'Awaiting Import',
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
},
available: {
label: 'Available',
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
},
completed: {
label: 'Available',
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
},
failed: {
label: 'Failed',
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
},
warn: {
label: 'Warning',
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
},
cancelled: {
label: 'Cancelled',
color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
},
};
const config = statusConfig[status] || {
label: status,
color: 'bg-gray-100 text-gray-800',
};
return (
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
config.color,
className
)}
>
{config.label}
</span>
);
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Component: Alert Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
import { Modal } from './Modal';
import { Button } from './Button';
interface AlertModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
message: string;
buttonText?: string;
variant?: 'info' | 'warning' | 'success' | 'danger';
}
export function AlertModal({
isOpen,
onClose,
title,
message,
buttonText = 'OK',
variant = 'info',
}: AlertModalProps) {
const iconMap = {
info: (
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-6 h-6 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
success: (
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
danger: (
<svg className="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
<div className="space-y-6">
<div className="flex gap-4">
<div className="flex-shrink-0">
{iconMap[variant]}
</div>
<p className="text-gray-600 dark:text-gray-400 flex-1">{message}</p>
</div>
<div className="flex justify-end">
<Button onClick={onClose} variant="primary">
{buttonText}
</Button>
</div>
</div>
</Modal>
);
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Component: Button
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils/cn';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
icon?: React.ReactNode;
}
export function Button({
children,
variant = 'primary',
size = 'md',
loading = false,
disabled,
icon,
className,
...props
}: ButtonProps) {
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-blue-500 dark:hover:bg-blue-950',
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500 dark:text-gray-300 dark:hover:bg-gray-800',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
return (
<button
className={cn(
baseStyles,
variants[variant],
sizes[size],
className
)}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{icon && !loading && <span className="mr-2">{icon}</span>}
{children}
</button>
);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Component: Confirmation Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
import { Modal } from './Modal';
import { Button } from './Button';
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
isLoading?: boolean;
variant?: 'danger' | 'primary';
}
export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
isLoading = false,
variant = 'primary',
}: ConfirmModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
<div className="space-y-6">
<p className="text-gray-600 dark:text-gray-400">{message}</p>
<div className="flex gap-3 justify-end">
<Button onClick={onClose} variant="outline" disabled={isLoading}>
{cancelText}
</Button>
<Button
onClick={onConfirm}
variant={variant}
loading={isLoading}
>
{confirmText}
</Button>
</div>
</div>
</Modal>
);
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Component: Input
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { InputHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils/cn';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, className, id, ...props }, ref) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
const baseStyles =
'block w-full rounded-lg border px-4 py-2 text-base transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const stateStyles = error
? 'border-red-500 focus:border-red-500 focus:ring-red-500 text-red-900 placeholder-red-300 dark:text-red-100'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white';
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={cn(baseStyles, stateStyles, className)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={
error
? `${inputId}-error`
: helperText
? `${inputId}-helper`
: undefined
}
{...props}
/>
{error && (
<p
id={`${inputId}-error`}
className="mt-2 text-sm text-red-600 dark:text-red-400"
>
{error}
</p>
)}
{helperText && !error && (
<p
id={`${inputId}-helper`}
className="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
{helperText}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
+111
View File
@@ -0,0 +1,111 @@
/**
* Component: Modal Dialog
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useEffect } from 'react';
import { cn } from '@/lib/utils/cn';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
showCloseButton?: boolean;
}
export function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
showCloseButton = true,
}: ModalProps) {
// Close on ESC key
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEsc);
// Prevent body scroll when modal is open
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-6xl',
full: 'max-w-[95vw]',
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={onClose}
/>
{/* Modal container */}
<div className="flex min-h-full items-center justify-center p-4">
<div
className={cn(
'relative w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl',
'transform transition-all',
sizeClasses[size]
)}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{title}
</h2>
{showCloseButton && (
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
{/* Content */}
<div className="px-6 py-4 max-h-[calc(100vh-200px)] overflow-y-auto">
{children}
</div>
</div>
</div>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
/**
* Component: Pagination Component
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
className?: string;
}
export function Pagination({ currentPage, totalPages, onPageChange, className = '' }: PaginationProps) {
if (totalPages <= 1) {
return null;
}
const generatePageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisible = 7; // Show max 7 page buttons
if (totalPages <= maxVisible) {
// Show all pages if total is less than max
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (currentPage > 3) {
pages.push('...');
}
// Show pages around current page
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push('...');
}
// Always show last page
pages.push(totalPages);
}
return pages;
};
const pageNumbers = generatePageNumbers();
return (
<div className={`flex items-center justify-center gap-2 ${className}`}>
{/* Previous Button */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
hover:bg-gray-50 dark:hover:bg-gray-700
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
aria-label="Previous page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Page Numbers */}
<div className="flex items-center gap-1">
{pageNumbers.map((page, index) => {
if (page === '...') {
return (
<span
key={`ellipsis-${index}`}
className="px-3 py-2 text-gray-500 dark:text-gray-400"
>
...
</span>
);
}
const pageNum = page as number;
const isActive = pageNum === currentPage;
return (
<button
key={pageNum}
onClick={() => onPageChange(pageNum)}
className={`px-4 py-2 rounded-lg font-medium transition-colors
${
isActive
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
aria-label={`Page ${pageNum}`}
aria-current={isActive ? 'page' : undefined}
>
{pageNum}
</button>
);
})}
</div>
{/* Next Button */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
hover:bg-gray-50 dark:hover:bg-gray-700
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
aria-label="Next page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Component: Sticky Pagination with Progress Bar
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
interface StickyPaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
sectionRef: React.RefObject<HTMLElement | null>;
label: string; // e.g., "Popular Audiobooks"
}
export function StickyPagination({
currentPage,
totalPages,
onPageChange,
sectionRef,
label,
}: StickyPaginationProps) {
const [isVisible, setIsVisible] = useState(false);
const [jumpPage, setJumpPage] = useState(currentPage.toString());
// Update jump page input when current page changes externally
useEffect(() => {
setJumpPage(currentPage.toString());
}, [currentPage]);
// Intersection Observer to show/hide pagination based on section visibility
useEffect(() => {
if (!sectionRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
// Show pagination when section is in viewport
setIsVisible(entry.isIntersecting && entry.intersectionRatio > 0.1);
},
{
threshold: [0, 0.1, 0.5, 1],
rootMargin: '-60px 0px -60px 0px', // Account for header/footer
}
);
observer.observe(sectionRef.current);
return () => observer.disconnect();
}, [sectionRef]);
if (totalPages <= 1) {
return null;
}
const handlePrevious = () => {
if (currentPage > 1) {
onPageChange(currentPage - 1);
}
};
const handleNext = () => {
if (currentPage < totalPages) {
onPageChange(currentPage + 1);
}
};
const handleJumpSubmit = (e: React.FormEvent) => {
e.preventDefault();
const page = parseInt(jumpPage, 10);
if (!isNaN(page) && page >= 1 && page <= totalPages) {
onPageChange(page);
} else {
// Reset to current page if invalid
setJumpPage(currentPage.toString());
}
};
return (
<div
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-40 transition-all duration-300 ${
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0'
}`}
>
<div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg rounded-full shadow-lg border border-gray-200 dark:border-gray-700 px-4 py-2.5">
<div className="flex items-center gap-3">
{/* Section Label - Hidden on small screens */}
<div className="hidden md:block text-xs font-medium text-gray-600 dark:text-gray-400 pr-2 border-r border-gray-300 dark:border-gray-600">
{label}
</div>
{/* Previous Button */}
<button
onClick={handlePrevious}
disabled={currentPage === 1}
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
transition-colors"
aria-label="Previous page"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
{/* Page Info & Jump */}
<div className="flex items-center gap-1.5">
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
Page
</span>
<form onSubmit={handleJumpSubmit} className="inline-flex">
<input
type="text"
value={jumpPage}
onChange={(e) => setJumpPage(e.target.value)}
onBlur={handleJumpSubmit}
className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded
bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100
border border-gray-300 dark:border-gray-600
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
aria-label="Current page"
/>
</form>
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
of {totalPages}
</span>
</div>
{/* Next Button */}
<button
onClick={handleNext}
disabled={currentPage === totalPages}
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
transition-colors"
aria-label="Next page"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Component: Toast Notification System
* Documentation: documentation/frontend/components.md
*/
'use client';
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
interface ToastContextType {
toasts: Toast[];
addToast: (message: string, type: ToastType, duration?: number) => void;
removeToast: (id: string) => void;
success: (message: string, duration?: number) => void;
error: (message: string, duration?: number) => void;
info: (message: string, duration?: number) => void;
warning: (message: string, duration?: number) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const addToast = useCallback(
(message: string, type: ToastType, duration: number = 5000) => {
const id = `toast-${Date.now()}-${Math.random()}`;
const newToast: Toast = { id, message, type, duration };
setToasts((prev) => [...prev, newToast]);
if (duration > 0) {
setTimeout(() => {
removeToast(id);
}, duration);
}
},
[removeToast]
);
const success = useCallback(
(message: string, duration?: number) => addToast(message, 'success', duration),
[addToast]
);
const error = useCallback(
(message: string, duration?: number) => addToast(message, 'error', duration),
[addToast]
);
const info = useCallback(
(message: string, duration?: number) => addToast(message, 'info', duration),
[addToast]
);
const warning = useCallback(
(message: string, duration?: number) => addToast(message, 'warning', duration),
[addToast]
);
return (
<ToastContext.Provider
value={{
toasts,
addToast,
removeToast,
success,
error,
info,
warning,
}}
>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
function ToastContainer({
toasts,
onRemove,
}: {
toasts: Toast[];
onRemove: (id: string) => void;
}) {
if (toasts.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
))}
</div>
);
}
function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) => void }) {
const colors = {
success: 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200',
error: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200',
warning: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200',
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200',
};
const icons = {
success: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
error: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
warning: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
),
info: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
};
return (
<div
className={`flex items-start gap-3 px-4 py-3 rounded-lg border shadow-lg min-w-[300px] max-w-md pointer-events-auto animate-slide-in-right ${
colors[toast.type]
}`}
>
<div className="flex-shrink-0">{icons[toast.type]}</div>
<div className="flex-1 text-sm font-medium">{toast.message}</div>
<button
onClick={() => onRemove(toast.id)}
className="flex-shrink-0 hover:opacity-70 transition-opacity"
aria-label="Close"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
);
}
/**
* Confirmation Dialog Hook
*/
export function useConfirm() {
return useCallback((message: string): Promise<boolean> => {
return new Promise((resolve) => {
const result = window.confirm(message);
resolve(result);
});
}, []);
}