mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user