mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add BookDate card stack animations and thumbnail caching
Implements pure CSS card stack animations for BookDate recommendations, including smooth exit and advance transitions. Adds local caching of library cover thumbnails during scans, updates database schema and API to serve cached covers, and enhances BookDate to support 'favorites' scope with a book picker modal. Updates admin settings validation logic for Prowlarr, improves indexer state management, and documents new features and backend changes.
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Component: BookDate Book Picker Modal
|
||||
* Documentation: documentation/features/bookdate.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
interface BookPickerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedIds: string[];
|
||||
onConfirm: (selectedIds: string[]) => void;
|
||||
maxSelection: number;
|
||||
}
|
||||
|
||||
interface LibraryBook {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl?: string | null;
|
||||
}
|
||||
|
||||
export function BookPickerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedIds,
|
||||
onConfirm,
|
||||
maxSelection,
|
||||
}: BookPickerModalProps) {
|
||||
const [books, setBooks] = useState<LibraryBook[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [localSelectedIds, setLocalSelectedIds] = useState<string[]>(selectedIds);
|
||||
|
||||
// Infinite scroll state
|
||||
const [displayedCount, setDisplayedCount] = useState(100); // Start with 100 books
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load library books when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadLibraryBooks();
|
||||
setLocalSelectedIds(selectedIds); // Reset to initial selection when reopening
|
||||
setDisplayedCount(100); // Reset displayed count
|
||||
setSearchQuery(''); // Reset search
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadLibraryBooks = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
const response = await fetch('/api/bookdate/library', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load library books');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setBooks(data.books || []);
|
||||
} catch (error: any) {
|
||||
console.error('Load library books error:', error);
|
||||
setError(error.message || 'Failed to load library books');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBook = (bookId: string) => {
|
||||
setLocalSelectedIds(prev => {
|
||||
if (prev.includes(bookId)) {
|
||||
// Deselect
|
||||
return prev.filter(id => id !== bookId);
|
||||
} else {
|
||||
// Select (only if under max)
|
||||
if (prev.length < maxSelection) {
|
||||
return [...prev, bookId];
|
||||
}
|
||||
return prev; // Already at max
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Reset displayed count when search query changes
|
||||
const handleSearchChange = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
setDisplayedCount(100); // Reset to show first 100 results
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(localSelectedIds);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setLocalSelectedIds(selectedIds); // Reset to original
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Filter books by search query
|
||||
const filteredBooks = books.filter(book => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
book.title.toLowerCase().includes(query) ||
|
||||
book.author.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Only display a subset for performance (infinite scroll)
|
||||
const displayedBooks = filteredBooks.slice(0, displayedCount);
|
||||
const hasMore = displayedCount < filteredBooks.length;
|
||||
|
||||
const isMaxReached = localSelectedIds.length >= maxSelection;
|
||||
|
||||
// Infinite scroll observer
|
||||
useEffect(() => {
|
||||
const currentFilteredLength = filteredBooks.length;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !loading) {
|
||||
// Load more books when bottom sentinel is visible
|
||||
setDisplayedCount(prev => Math.min(prev + 100, currentFilteredLength));
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
const currentTarget = observerTarget.current;
|
||||
if (currentTarget) {
|
||||
observer.observe(currentTarget);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentTarget) {
|
||||
observer.unobserve(currentTarget);
|
||||
}
|
||||
};
|
||||
}, [loading, filteredBooks.length]); // Re-run when loading state or filtered length changes
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40 transition-opacity"
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-4xl bg-white dark:bg-gray-800 rounded-xl shadow-2xl z-50 max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Select Your Favorite Books
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Choose up to {maxSelection} books that represent your favorites
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selection Counter */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`text-sm font-medium ${isMaxReached ? 'text-orange-600 dark:text-orange-400' : 'text-blue-600 dark:text-blue-400'}`}>
|
||||
{localSelectedIds.length} / {maxSelection} selected
|
||||
{isMaxReached && (
|
||||
<span className="ml-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
(Maximum reached)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{localSelectedIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => setLocalSelectedIds([])}
|
||||
className="text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 font-medium px-2 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Search books..."
|
||||
className="w-64 px-4 py-2 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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Books Grid */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 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">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredBooks.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
{searchQuery ? 'No books match your search' : 'No books in your library'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{displayedBooks.map((book, index) => {
|
||||
const isSelected = localSelectedIds.includes(book.id);
|
||||
const isDisabled = !isSelected && isMaxReached;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={book.id}
|
||||
onClick={() => !isDisabled && toggleBook(book.id)}
|
||||
disabled={isDisabled}
|
||||
className={`group relative aspect-[2/3] rounded-lg overflow-hidden transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'ring-4 ring-blue-500 shadow-lg scale-105'
|
||||
: isDisabled
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: 'hover:scale-105 hover:shadow-md'
|
||||
}`}
|
||||
style={{
|
||||
animationDelay: `${index * 20}ms`,
|
||||
animation: 'fadeIn 0.3s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{/* Cover Image or Text Placeholder */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-gray-700 dark:to-gray-600">
|
||||
{book.coverUrl ? (
|
||||
<img
|
||||
src={book.coverUrl}
|
||||
alt={book.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center p-3">
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 line-clamp-4 mb-1">
|
||||
{book.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{book.author}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Overlay */}
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 bg-blue-600/20 flex items-center justify-center">
|
||||
<div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Book Info on Hover */}
|
||||
{!isSelected && !isDisabled && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="text-white text-xs font-medium line-clamp-2">
|
||||
{book.title}
|
||||
</div>
|
||||
<div className="text-white/80 text-xs line-clamp-1 mt-1">
|
||||
{book.author}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
{!loading && !error && hasMore && (
|
||||
<div ref={observerTarget} className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show count info */}
|
||||
{!loading && !error && filteredBooks.length > 0 && (
|
||||
<div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing {displayedBooks.length} of {filteredBooks.length} books
|
||||
{filteredBooks.length !== books.length && ` (filtered from ${books.length} total)`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
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>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={localSelectedIds.length === 0}
|
||||
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"
|
||||
>
|
||||
Confirm Selection ({localSelectedIds.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fade-in animation */}
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Component: BookDate Card Stack
|
||||
* Documentation: documentation/features/bookdate-animations.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { RecommendationCard } from './RecommendationCard';
|
||||
|
||||
interface CardStackProps {
|
||||
recommendations: any[];
|
||||
currentIndex: number;
|
||||
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
|
||||
onSwipeComplete: () => void;
|
||||
}
|
||||
|
||||
export function CardStack({
|
||||
recommendations,
|
||||
currentIndex,
|
||||
onSwipe,
|
||||
onSwipeComplete,
|
||||
}: CardStackProps) {
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const [exitDirection, setExitDirection] = useState<'left' | 'right' | 'up' | null>(null);
|
||||
const [isAdvancing, setIsAdvancing] = useState(false);
|
||||
|
||||
// Reset animation states when currentIndex changes externally (e.g., undo)
|
||||
useEffect(() => {
|
||||
setIsExiting(false);
|
||||
setExitDirection(null);
|
||||
setIsAdvancing(false);
|
||||
}, [currentIndex]);
|
||||
|
||||
const handleSwipeStart = useCallback(
|
||||
(action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => {
|
||||
// Prevent swipes during animation
|
||||
if (isExiting || isAdvancing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start exit animation
|
||||
setIsExiting(true);
|
||||
setExitDirection(action);
|
||||
|
||||
// Call parent's onSwipe (for API call)
|
||||
onSwipe(action, markedAsKnown);
|
||||
|
||||
// Wait for exit animation to complete (400ms)
|
||||
setTimeout(() => {
|
||||
setIsExiting(false);
|
||||
setExitDirection(null);
|
||||
|
||||
// Start advance animation
|
||||
setIsAdvancing(true);
|
||||
|
||||
// Wait for advance animation to complete (350ms)
|
||||
setTimeout(() => {
|
||||
setIsAdvancing(false);
|
||||
// Notify parent that animations are complete
|
||||
onSwipeComplete();
|
||||
}, 350);
|
||||
}, 400);
|
||||
},
|
||||
[isExiting, isAdvancing, onSwipe, onSwipeComplete]
|
||||
);
|
||||
|
||||
// Get up to 3 cards to display
|
||||
const visibleCards = [];
|
||||
|
||||
if (isAdvancing) {
|
||||
// During advance, skip the card that just exited (at currentIndex)
|
||||
// Show cards at indices: currentIndex+1, currentIndex+2, currentIndex+3
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const index = currentIndex + 1 + i;
|
||||
if (index < recommendations.length) {
|
||||
visibleCards.push({
|
||||
recommendation: recommendations[index],
|
||||
index,
|
||||
stackPosition: i, // Target position (0, 1, 2)
|
||||
fromPosition: i + 1, // Source position for animation (1, 2, 3)
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal rendering: show current card and next 2
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const index = currentIndex + i;
|
||||
if (index < recommendations.length) {
|
||||
visibleCards.push({
|
||||
recommendation: recommendations[index],
|
||||
index,
|
||||
stackPosition: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have no cards, return null
|
||||
if (visibleCards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-stack-container relative w-full max-w-md h-[calc(80vh)] md:h-[calc(85vh)]">
|
||||
{visibleCards.map((card, arrayIndex) => {
|
||||
const isTopCard = card.stackPosition === 0;
|
||||
const isExitingCard = isTopCard && isExiting;
|
||||
|
||||
// Determine animation class
|
||||
let animationClass = '';
|
||||
if (isExitingCard && exitDirection) {
|
||||
animationClass = `animate-exit-${exitDirection}`;
|
||||
} else if (isAdvancing && card.fromPosition !== undefined) {
|
||||
// Cards are advancing from their previous position
|
||||
if (card.fromPosition === 1) {
|
||||
animationClass = 'animate-advance-to-top'; // 1 → 0
|
||||
} else if (card.fromPosition === 2) {
|
||||
animationClass = 'animate-advance-to-middle'; // 2 → 1
|
||||
} else if (card.fromPosition === 3) {
|
||||
animationClass = 'animate-enter'; // 3 → 2 (new card)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine static position class (when not animating)
|
||||
const positionClass = !animationClass
|
||||
? `card-stack-position-${card.stackPosition}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.index}
|
||||
className={`card-stack-item absolute inset-0 ${positionClass} ${animationClass}`}
|
||||
style={{
|
||||
// Ensure proper stacking even without animation
|
||||
zIndex: 50 - card.stackPosition * 10,
|
||||
}}
|
||||
>
|
||||
<RecommendationCard
|
||||
recommendation={card.recommendation}
|
||||
onSwipe={handleSwipeStart}
|
||||
stackPosition={card.stackPosition}
|
||||
isAnimating={isExiting || isAdvancing}
|
||||
isDraggable={isTopCard && !isExiting && !isAdvancing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,11 +12,17 @@ import { useSwipeable } from 'react-swipeable';
|
||||
interface RecommendationCardProps {
|
||||
recommendation: any;
|
||||
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
|
||||
stackPosition?: number; // 0 = top, 1 = middle, 2 = bottom
|
||||
isAnimating?: boolean; // True during exit/advance animations
|
||||
isDraggable?: boolean; // False for cards behind the top card
|
||||
}
|
||||
|
||||
export function RecommendationCard({
|
||||
recommendation,
|
||||
onSwipe,
|
||||
stackPosition = 0,
|
||||
isAnimating = false,
|
||||
isDraggable = true,
|
||||
}: RecommendationCardProps) {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
@@ -36,9 +42,18 @@ export function RecommendationCard({
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwiping: (eventData) => {
|
||||
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
|
||||
// Only update drag offset if card is draggable and not animating
|
||||
if (isDraggable && !isAnimating) {
|
||||
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
|
||||
}
|
||||
},
|
||||
onSwiped: (eventData) => {
|
||||
// Only process swipe if card is draggable and not animating
|
||||
if (!isDraggable || isAnimating) {
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check final position when user releases - must be at 100px threshold
|
||||
const finalX = eventData.deltaX;
|
||||
const finalY = eventData.deltaY;
|
||||
@@ -187,27 +202,32 @@ export function RecommendationCard({
|
||||
)}
|
||||
</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>
|
||||
{/* Desktop buttons - only show for top card */}
|
||||
{stackPosition === 0 && (
|
||||
<div className="hidden md:flex justify-center gap-4 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => !isAnimating && onSwipe('left')}
|
||||
disabled={isAnimating}
|
||||
className="px-6 py-3 bg-red-500 hover:bg-red-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
❌ Not Interested
|
||||
</button>
|
||||
<button
|
||||
onClick={() => !isAnimating && onSwipe('up')}
|
||||
disabled={isAnimating}
|
||||
className="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
⬆️ Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={() => !isAnimating && handleSwipeRight()}
|
||||
disabled={isAnimating}
|
||||
className="px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
✅ Request
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation Toast */}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BookPickerModal } from './BookPickerModal';
|
||||
|
||||
interface SettingsWidgetProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,7 +16,9 @@ interface SettingsWidgetProps {
|
||||
}
|
||||
|
||||
export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboardingComplete }: SettingsWidgetProps) {
|
||||
const [libraryScope, setLibraryScope] = useState<'full' | 'rated'>('full');
|
||||
const [libraryScope, setLibraryScope] = useState<'full' | 'rated' | 'favorites'>('full');
|
||||
const [favoriteBookIds, setFavoriteBookIds] = useState<string[]>([]);
|
||||
const [showBookPicker, setShowBookPicker] = useState(false);
|
||||
const [customPrompt, setCustomPrompt] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -52,6 +55,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
|
||||
const data = await response.json();
|
||||
setLibraryScope(data.libraryScope || 'full');
|
||||
setFavoriteBookIds(data.favoriteBookIds || []);
|
||||
setCustomPrompt(data.customPrompt || '');
|
||||
setBackendCapabilities(data.backendCapabilities || { supportsRatings: true });
|
||||
} catch (error: any) {
|
||||
@@ -63,6 +67,12 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate favorites scope
|
||||
if (libraryScope === 'favorites' && favoriteBookIds.length === 0) {
|
||||
setError('Please select at least 1 favorite book');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
@@ -78,6 +88,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
},
|
||||
body: JSON.stringify({
|
||||
libraryScope,
|
||||
favoriteBookIds: libraryScope === 'favorites' ? favoriteBookIds : undefined,
|
||||
customPrompt: trimmedPrompt || null, // Send null if empty
|
||||
onboardingComplete: isOnboarding ? true : undefined,
|
||||
}),
|
||||
@@ -179,7 +190,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
name="libraryScope"
|
||||
value="full"
|
||||
checked={libraryScope === 'full'}
|
||||
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated')}
|
||||
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated' | 'favorites')}
|
||||
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
@@ -200,7 +211,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
name="libraryScope"
|
||||
value="rated"
|
||||
checked={libraryScope === 'rated'}
|
||||
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated')}
|
||||
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated' | 'favorites')}
|
||||
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
@@ -214,19 +225,52 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Show info message if ratings not supported */}
|
||||
{!backendCapabilities.supportsRatings && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
Note: Your backend does not support user ratings. Only "Full Library" scope is available.
|
||||
{/* Pick My Favorites */}
|
||||
<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 === 'favorites' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-600'}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="libraryScope"
|
||||
value="favorites"
|
||||
checked={libraryScope === 'favorites'}
|
||||
onChange={(e) => {
|
||||
setLibraryScope('favorites');
|
||||
setShowBookPicker(true); // Auto-open book picker
|
||||
}}
|
||||
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">
|
||||
Pick my favorites
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Select up to 25 books as your personalized library
|
||||
{favoriteBookIds.length > 0 && (
|
||||
<span className="ml-2 text-blue-600 dark:text-blue-400 font-medium">
|
||||
({favoriteBookIds.length} selected)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{libraryScope === 'favorites' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowBookPicker(true);
|
||||
}}
|
||||
className="mt-2 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium"
|
||||
>
|
||||
{favoriteBookIds.length > 0 ? 'Change Selection' : 'Choose Books'}
|
||||
</button>
|
||||
)}
|
||||
</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
|
||||
Special Requests
|
||||
<span className="text-gray-500 dark:text-gray-400 font-normal ml-2">
|
||||
(Optional)
|
||||
</span>
|
||||
@@ -268,6 +312,15 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Book Picker Modal */}
|
||||
<BookPickerModal
|
||||
isOpen={showBookPicker}
|
||||
onClose={() => setShowBookPicker(false)}
|
||||
selectedIds={favoriteBookIds}
|
||||
onConfirm={(ids) => setFavoriteBookIds(ids)}
|
||||
maxSelection={25}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user