mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40: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:
@@ -163,6 +163,7 @@ export const validateAuthSettings = (settings: Settings): { valid: boolean; mess
|
||||
export const getTabValidation = (
|
||||
activeTab: SettingsTab,
|
||||
settings: Settings,
|
||||
originalSettings: Settings | null,
|
||||
validated: {
|
||||
plex: boolean;
|
||||
audiobookshelf: boolean;
|
||||
@@ -179,7 +180,15 @@ export const getTabValidation = (
|
||||
case 'auth':
|
||||
return validated.oidc || validated.registration;
|
||||
case 'prowlarr':
|
||||
return validated.prowlarr;
|
||||
// Only require validation if URL or API key changed
|
||||
// If only indexers/flags changed, allow saving without test
|
||||
if (!originalSettings) return validated.prowlarr;
|
||||
|
||||
const prowlarrConnectionChanged =
|
||||
settings.prowlarr.url !== originalSettings.prowlarr.url ||
|
||||
settings.prowlarr.apiKey !== originalSettings.prowlarr.apiKey;
|
||||
|
||||
return prowlarrConnectionChanged ? validated.prowlarr : true;
|
||||
case 'download':
|
||||
return validated.download;
|
||||
case 'paths':
|
||||
|
||||
@@ -49,7 +49,9 @@ export default function AdminSettings() {
|
||||
|
||||
// Indexer-specific state (used by IndexersTab)
|
||||
const [configuredIndexers, setConfiguredIndexers] = useState<SavedIndexerConfig[]>([]);
|
||||
const [originalConfiguredIndexers, setOriginalConfiguredIndexers] = useState<SavedIndexerConfig[]>([]);
|
||||
const [flagConfigs, setFlagConfigs] = useState<IndexerFlagConfig[]>([]);
|
||||
const [originalFlagConfigs, setOriginalFlagConfigs] = useState<IndexerFlagConfig[]>([]);
|
||||
|
||||
// Initial data fetch
|
||||
useEffect(() => {
|
||||
@@ -89,7 +91,9 @@ export default function AdminSettings() {
|
||||
const response = await fetchWithAuth('/api/admin/settings/prowlarr/indexers');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFlagConfigs(data.flagConfigs || []);
|
||||
const flags = data.flagConfigs || [];
|
||||
setFlagConfigs(flags);
|
||||
setOriginalFlagConfigs(JSON.parse(JSON.stringify(flags)));
|
||||
|
||||
// Extract configured indexers (enabled ones)
|
||||
const configured = (data.indexers || [])
|
||||
@@ -103,6 +107,7 @@ export default function AdminSettings() {
|
||||
categories: idx.categories || [3030],
|
||||
}));
|
||||
setConfiguredIndexers(configured);
|
||||
setOriginalConfiguredIndexers(JSON.parse(JSON.stringify(configured)));
|
||||
} else {
|
||||
console.error('Failed to fetch indexers:', response.status);
|
||||
if (force) {
|
||||
@@ -139,6 +144,13 @@ export default function AdminSettings() {
|
||||
await saveTabSettings(activeTab, settings, configuredIndexers, flagConfigs);
|
||||
setMessage({ type: 'success', text: 'Settings saved successfully!' });
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(settings)));
|
||||
|
||||
// Also update original indexers and flag configs when saving prowlarr tab
|
||||
if (activeTab === 'prowlarr') {
|
||||
setOriginalConfiguredIndexers(JSON.parse(JSON.stringify(configuredIndexers)));
|
||||
setOriginalFlagConfigs(JSON.parse(JSON.stringify(flagConfigs)));
|
||||
}
|
||||
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
@@ -161,8 +173,21 @@ export default function AdminSettings() {
|
||||
|
||||
// Dynamic tabs, validation, and change detection
|
||||
const tabs = getTabs(settings.backendMode);
|
||||
const currentTabValidation = getTabValidation(activeTab, settings, validated);
|
||||
const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(originalSettings);
|
||||
const currentTabValidation = getTabValidation(activeTab, settings, originalSettings, validated);
|
||||
|
||||
// Check for unsaved changes in settings and indexer-specific state
|
||||
const hasUnsavedChanges = (() => {
|
||||
const settingsChanged = JSON.stringify(settings) !== JSON.stringify(originalSettings);
|
||||
|
||||
// For prowlarr tab, also check indexers and flag configs
|
||||
if (activeTab === 'prowlarr') {
|
||||
const indexersChanged = JSON.stringify(configuredIndexers) !== JSON.stringify(originalConfiguredIndexers);
|
||||
const flagConfigsChanged = JSON.stringify(flagConfigs) !== JSON.stringify(originalFlagConfigs);
|
||||
return settingsChanged || indexersChanged || flagConfigsChanged;
|
||||
}
|
||||
|
||||
return settingsChanged;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
@@ -315,7 +340,9 @@ export default function AdminSettings() {
|
||||
</Button>
|
||||
{!currentTabValidation && hasUnsavedChanges && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 self-center">
|
||||
Please test the connection before saving
|
||||
{activeTab === 'prowlarr'
|
||||
? 'Please test the Prowlarr connection before saving'
|
||||
: 'Please test the connection before saving'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Component: BookDate Library API
|
||||
* Documentation: documentation/features/bookdate.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.BookDate.Library');
|
||||
|
||||
/**
|
||||
* GET /api/bookdate/library
|
||||
* Get user's full library for book picker modal
|
||||
* Returns: id, title, author, coverUrl (thumbnail)
|
||||
*/
|
||||
async function getLibraryBooks(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Get library ID based on backend mode
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
|
||||
let libraryId: string;
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
const absLibraryId = await configService.get('audiobookshelf.library_id');
|
||||
if (!absLibraryId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No Audiobookshelf library ID configured' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
libraryId = absLibraryId;
|
||||
} else {
|
||||
// Plex mode
|
||||
const plexConfig = await configService.getPlexConfig();
|
||||
if (!plexConfig.libraryId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No Plex library ID configured' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
libraryId = plexConfig.libraryId;
|
||||
}
|
||||
|
||||
// Fetch ALL books from library (no limit - client handles pagination/infinite scroll)
|
||||
// Join with AudibleCache to get cached cover images
|
||||
const books = await prisma.plexLibrary.findMany({
|
||||
where: { plexLibraryId: libraryId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
author: true,
|
||||
asin: true, // For joining with AudibleCache
|
||||
cachedLibraryCoverPath: true, // For library cached covers
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
});
|
||||
|
||||
logger.info(`Fetched ${books.length} books from library for user ${userId}`);
|
||||
|
||||
// Get ASINs for books that have them
|
||||
const asins = books.map(b => b.asin).filter((asin): asin is string => !!asin);
|
||||
|
||||
// Fetch cached covers from AudibleCache (only for books with ASINs)
|
||||
const cachedCovers = await prisma.audibleCache.findMany({
|
||||
where: {
|
||||
asin: { in: asins },
|
||||
},
|
||||
select: {
|
||||
asin: true,
|
||||
coverArtUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create ASIN -> coverUrl map
|
||||
const coverMap = new Map<string, string>();
|
||||
cachedCovers.forEach(cache => {
|
||||
if (cache.coverArtUrl) {
|
||||
coverMap.set(cache.asin, cache.coverArtUrl);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Found ${coverMap.size} cached covers out of ${asins.length} books with ASINs`);
|
||||
|
||||
// Map books with their covers (priority: library cache > Audible cache > null)
|
||||
return NextResponse.json({
|
||||
books: books.map(book => {
|
||||
let coverUrl: string | null = null;
|
||||
|
||||
// Priority 1: Library cached cover (most books should have this)
|
||||
if (book.cachedLibraryCoverPath) {
|
||||
const filename = book.cachedLibraryCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/library/${filename}`;
|
||||
}
|
||||
// Priority 2: Audible cache (fallback for books with ASIN but no library cache)
|
||||
else if (book.asin && coverMap.has(book.asin)) {
|
||||
coverUrl = coverMap.get(book.asin)!;
|
||||
}
|
||||
// Priority 3: null (show placeholder)
|
||||
|
||||
return {
|
||||
id: book.id,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Get library books error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to fetch library books' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return requireAuth(req, getLibraryBooks);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ async function getPreferences(req: AuthenticatedRequest) {
|
||||
where: { id: userId },
|
||||
select: {
|
||||
bookDateLibraryScope: true,
|
||||
bookDateFavoriteBookIds: true,
|
||||
bookDateCustomPrompt: true,
|
||||
bookDateOnboardingComplete: true,
|
||||
},
|
||||
@@ -49,6 +50,7 @@ async function getPreferences(req: AuthenticatedRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
libraryScope: effectiveScope,
|
||||
favoriteBookIds: user.bookDateFavoriteBookIds ? JSON.parse(user.bookDateFavoriteBookIds) : [],
|
||||
customPrompt: user.bookDateCustomPrompt || '', // Always return empty string for UI
|
||||
onboardingComplete: user.bookDateOnboardingComplete || false,
|
||||
backendCapabilities: {
|
||||
@@ -75,12 +77,28 @@ async function updatePreferences(req: AuthenticatedRequest) {
|
||||
|
||||
// Parse request body
|
||||
const body = await req.json();
|
||||
const { libraryScope, customPrompt, onboardingComplete } = body;
|
||||
const { libraryScope, favoriteBookIds, customPrompt, onboardingComplete } = body;
|
||||
|
||||
// Validate library scope
|
||||
if (libraryScope && !['full', 'rated'].includes(libraryScope)) {
|
||||
if (libraryScope && !['full', 'rated', 'favorites'].includes(libraryScope)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid library scope. Must be "full" or "rated"' },
|
||||
{ error: 'Invalid library scope. Must be "full", "rated", or "favorites"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate favorites scope requirements
|
||||
if (libraryScope === 'favorites' && (!favoriteBookIds || favoriteBookIds.length === 0)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Favorites scope requires at least 1 favorite book selected' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate favorite books limit
|
||||
if (favoriteBookIds && favoriteBookIds.length > 25) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Maximum 25 favorite books allowed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -110,6 +128,12 @@ async function updatePreferences(req: AuthenticatedRequest) {
|
||||
if (libraryScope !== undefined) {
|
||||
updateData.bookDateLibraryScope = libraryScope || 'full';
|
||||
}
|
||||
if (favoriteBookIds !== undefined) {
|
||||
// Store as JSON string
|
||||
updateData.bookDateFavoriteBookIds = favoriteBookIds && favoriteBookIds.length > 0
|
||||
? JSON.stringify(favoriteBookIds)
|
||||
: null;
|
||||
}
|
||||
if (customPrompt !== undefined) {
|
||||
// Normalize empty strings to null for consistency
|
||||
const normalizedPrompt = (typeof customPrompt === 'string' && customPrompt.trim()) ? customPrompt.trim() : null;
|
||||
@@ -125,6 +149,7 @@ async function updatePreferences(req: AuthenticatedRequest) {
|
||||
data: updateData,
|
||||
select: {
|
||||
bookDateLibraryScope: true,
|
||||
bookDateFavoriteBookIds: true,
|
||||
bookDateCustomPrompt: true,
|
||||
bookDateOnboardingComplete: true,
|
||||
},
|
||||
@@ -133,6 +158,7 @@ async function updatePreferences(req: AuthenticatedRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
libraryScope: updatedUser.bookDateLibraryScope || 'full',
|
||||
favoriteBookIds: updatedUser.bookDateFavoriteBookIds ? JSON.parse(updatedUser.bookDateFavoriteBookIds) : [],
|
||||
customPrompt: updatedUser.bookDateCustomPrompt || '', // Always return empty string for UI
|
||||
onboardingComplete: updatedUser.bookDateOnboardingComplete || false,
|
||||
});
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Component: Library Cover Cache API Route
|
||||
* Documentation: documentation/features/library-thumbnail-cache.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.LibraryCovers');
|
||||
|
||||
const LIBRARY_CACHE_DIR = '/app/cache/library';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> }
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
|
||||
// Validate filename (prevent directory traversal)
|
||||
if (!filename || filename.includes('..') || filename.includes('/')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid filename' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const filePath = path.join(LIBRARY_CACHE_DIR, filename);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'File not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
|
||||
// Determine content type based on extension
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
|
||||
const contentType = contentTypeMap[ext] || 'application/octet-stream';
|
||||
|
||||
// Return the image with appropriate headers
|
||||
return new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error serving library cover', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+18
-13
@@ -8,7 +8,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { RecommendationCard } from '@/components/bookdate/RecommendationCard';
|
||||
import { CardStack } from '@/components/bookdate/CardStack';
|
||||
import { LoadingScreen } from '@/components/bookdate/LoadingScreen';
|
||||
import { SettingsWidget } from '@/components/bookdate/SettingsWidget';
|
||||
|
||||
@@ -150,13 +150,8 @@ export default function BookDatePage() {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Move to next recommendation
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
|
||||
// Check if we need to load more recommendations
|
||||
if (currentIndex + 1 >= recommendations.length) {
|
||||
// At the end - could auto-load more or show empty state
|
||||
}
|
||||
// Note: currentIndex is now incremented in handleSwipeComplete
|
||||
// after animations finish
|
||||
|
||||
} catch (error) {
|
||||
console.error('Swipe error:', error);
|
||||
@@ -164,6 +159,16 @@ export default function BookDatePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwipeComplete = () => {
|
||||
// Increment currentIndex after animations complete
|
||||
setCurrentIndex((prev) => prev + 1);
|
||||
|
||||
// Check if we need to load more recommendations
|
||||
if (currentIndex + 1 >= recommendations.length) {
|
||||
// At the end - could auto-load more or show empty state
|
||||
}
|
||||
};
|
||||
|
||||
const handleUndo = async () => {
|
||||
if (!lastSwipe || lastSwipe.action === 'right') {
|
||||
return;
|
||||
@@ -323,8 +328,6 @@ export default function BookDatePage() {
|
||||
);
|
||||
}
|
||||
|
||||
const currentRec = recommendations[currentIndex];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header />
|
||||
@@ -347,10 +350,12 @@ export default function BookDatePage() {
|
||||
{currentIndex + 1} / {recommendations.length}
|
||||
</div>
|
||||
|
||||
{/* Recommendation card */}
|
||||
<RecommendationCard
|
||||
recommendation={currentRec}
|
||||
{/* Card Stack */}
|
||||
<CardStack
|
||||
recommendations={recommendations}
|
||||
currentIndex={currentIndex}
|
||||
onSwipe={handleSwipe}
|
||||
onSwipeComplete={handleSwipeComplete}
|
||||
/>
|
||||
|
||||
{/* Undo button */}
|
||||
|
||||
@@ -39,3 +39,134 @@ body {
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* BookDate Card Stack Animations */
|
||||
|
||||
/* Exit animations - card swipes away */
|
||||
@keyframes card-exit-left {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-150%, 50px) rotate(-25deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes card-exit-right {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(150%, 50px) rotate(25deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes card-exit-up {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, -120%) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Advance animations - cards move forward in stack */
|
||||
@keyframes card-advance-to-top {
|
||||
0% {
|
||||
transform: scale(0.95) translateY(-12px);
|
||||
opacity: 0.95;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes card-advance-to-middle {
|
||||
0% {
|
||||
transform: scale(0.90) translateY(-24px);
|
||||
opacity: 0.90;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.95) translateY(-12px);
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enter animation - new card enters from bottom of stack */
|
||||
@keyframes card-enter {
|
||||
0% {
|
||||
transform: scale(0.85) translateY(-36px);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.90) translateY(-24px);
|
||||
opacity: 0.90;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
.animate-exit-left {
|
||||
animation: card-exit-left 400ms ease-in-out forwards;
|
||||
}
|
||||
|
||||
.animate-exit-right {
|
||||
animation: card-exit-right 400ms ease-in-out forwards;
|
||||
}
|
||||
|
||||
.animate-exit-up {
|
||||
animation: card-exit-up 400ms ease-in-out forwards;
|
||||
}
|
||||
|
||||
.animate-advance-to-top {
|
||||
animation: card-advance-to-top 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-advance-to-middle {
|
||||
animation: card-advance-to-middle 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-enter {
|
||||
animation: card-enter 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
/* Stack positioning classes */
|
||||
.card-stack-position-0 {
|
||||
z-index: 50;
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.card-stack-position-1 {
|
||||
z-index: 40;
|
||||
transform: scale(0.95) translateY(-12px);
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-stack-position-2 {
|
||||
z-index: 30;
|
||||
transform: scale(0.90) translateY(-24px);
|
||||
opacity: 0.90;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Performance optimizations */
|
||||
.card-stack-container {
|
||||
perspective: 1000px;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.card-stack-item {
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user