Add reported-issues, Goodreads sync & notifs

Introduce user-reported-issues and Goodreads shelf sync features and wire them into notifications. Adds Prisma migrations and schema changes (ReportedIssue, GoodreadsShelf, GoodreadsBookMapping), API endpoints for reporting (POST /audiobooks/[asin]/report-issue) and admin management (list, resolve/dismiss, replace), and an admin UI section to view/dismiss/replace reported issues. Adds a new notification event (issue_reported) with updates to notification schemas, docs and provider handling, plus a notification-events constants file. Refactors request creation to use createRequestForUser service, adds a Goodreads sync processor/service/hooks/UI modals, a scrape-resilience util, and related tests and minor integration updates.
This commit is contained in:
kikootwo
2026-02-11 16:49:55 -05:00
parent b013538b63
commit 20c8fb0898
69 changed files with 4167 additions and 766 deletions
@@ -0,0 +1,242 @@
/**
* Component: Admin Reported Issues Section
* Documentation: documentation/backend/services/reported-issues.md
*
* Displays open reported issues on the admin dashboard.
* Allows dismiss or search-for-replacement actions.
*/
'use client';
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useToast } from '@/components/ui/Toast';
import { formatDistanceToNow } from 'date-fns';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { fetchJSON } from '@/lib/utils/api';
import { mutate } from 'swr';
interface ReportedIssue {
id: string;
reason: string;
status: string;
createdAt: string;
audiobook: {
id: string;
title: string;
author: string;
coverArtUrl: string | null;
audibleAsin: string | null;
};
reporter: {
id: string;
plexUsername: string;
avatarUrl: string | null;
};
}
interface ReportedIssuesSectionProps {
issues: ReportedIssue[];
}
export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
const toast = useToast();
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
const [replaceIssue, setReplaceIssue] = useState<ReportedIssue | null>(null);
const handleDismiss = async (issueId: string) => {
setLoadingStates((prev) => ({ ...prev, [issueId]: true }));
try {
await fetchJSON(`/api/admin/reported-issues/${issueId}/resolve`, {
method: 'POST',
body: JSON.stringify({ action: 'dismiss' }),
});
toast.success('Issue dismissed');
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
} catch (error) {
toast.error(
`Failed to dismiss issue: ${error instanceof Error ? error.message : 'Unknown error'}`
);
} finally {
setLoadingStates((prev) => ({ ...prev, [issueId]: false }));
}
};
const handleReplaceSuccess = async () => {
toast.success('Replacement download started');
setReplaceIssue(null);
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/metrics'));
};
return (
<>
<div className="mb-8">
{/* Section Header */}
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-2">
<svg
className="w-6 h-6 text-orange-600 dark:text-orange-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"
/>
</svg>
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
Reported Issues
</h2>
</div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
{issues.length}
</span>
</div>
{/* Issues Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{issues.map((issue) => {
const isLoading = loadingStates[issue.id] || false;
return (
<div
key={issue.id}
className="bg-white dark:bg-gray-800 border-2 border-orange-200 dark:border-orange-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
>
{/* Card Content */}
<div className="p-4">
<div className="flex gap-3">
{/* Cover Image */}
<div className="flex-shrink-0">
{issue.audiobook.coverArtUrl ? (
<img
src={issue.audiobook.coverArtUrl}
alt={issue.audiobook.title}
className="w-16 h-16 rounded object-cover"
/>
) : (
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-400 dark:text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
{issue.audiobook.title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
{issue.audiobook.author}
</p>
{/* Reporter */}
<div className="flex items-center gap-2 mt-2">
{issue.reporter.avatarUrl ? (
<img
src={issue.reporter.avatarUrl}
alt={issue.reporter.plexUsername}
className="w-5 h-5 rounded-full"
/>
) : (
<div className="w-5 h-5 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
<svg
className="w-3 h-3 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clipRule="evenodd"
/>
</svg>
</div>
)}
<span className="text-xs text-gray-600 dark:text-gray-400">
{issue.reporter.plexUsername}
</span>
</div>
{/* Timestamp */}
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{formatDistanceToNow(new Date(issue.createdAt), { addSuffix: true })}
</p>
</div>
</div>
{/* Reason */}
<p className="mt-3 text-sm text-gray-700 dark:text-gray-300 line-clamp-2 break-words bg-orange-50 dark:bg-orange-900/20 rounded-lg px-3 py-2 border border-orange-100 dark:border-orange-800/50">
{issue.reason}
</p>
</div>
{/* Action Buttons */}
<div className="border-t border-orange-200 dark:border-orange-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
<button
onClick={() => handleDismiss(issue.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? (
<svg className="animate-spin h-4 w-4" 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>
) : (
<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>
)}
<span>Dismiss</span>
</button>
<button
onClick={() => setReplaceIssue(issue)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
<svg className="w-4 h-4" 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>
<span>Replace</span>
</button>
</div>
</div>
);
})}
</div>
</div>
{/* Interactive Search Modal for Replacement */}
{replaceIssue && createPortal(
<div className="fixed inset-0 z-[60]">
<InteractiveTorrentSearchModal
isOpen={!!replaceIssue}
onClose={() => setReplaceIssue(null)}
onSuccess={handleReplaceSuccess}
audiobook={{
title: replaceIssue.audiobook.title,
author: replaceIssue.audiobook.author,
}}
asin={replaceIssue.audiobook.audibleAsin || undefined}
replaceIssueId={replaceIssue.id}
/>
</div>,
document.body
)}
</>
);
}
+14
View File
@@ -12,6 +12,7 @@ import { MetricCard } from './components/MetricCard';
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react';
@@ -328,6 +329,14 @@ function AdminDashboardContent() {
}
);
const { data: reportedIssuesData } = useSWR(
'/api/admin/reported-issues',
authenticatedFetcher,
{
refreshInterval: 10000,
}
);
const { data: settingsData } = useSWR(
'/api/admin/settings',
authenticatedFetcher,
@@ -578,6 +587,11 @@ function AdminDashboardContent() {
<PendingApprovalSection requests={pendingApprovalData.requests} />
)}
{/* Reported Issues */}
{reportedIssuesData?.issues && reportedIssuesData.issues.length > 0 && (
<ReportedIssuesSection issues={reportedIssuesData.issues} />
)}
{/* Active Downloads */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { RMABLogger } from '@/lib/utils/logger';
import { fetchWithAuth } from '@/lib/utils/api';
import { EVENT_LABELS } from '@/lib/constants/notification-events';
const logger = RMABLogger.create('NotificationsTab');
@@ -43,12 +44,7 @@ interface ModalState {
backend?: NotificationBackend;
}
const eventLabels: Record<string, string> = {
request_pending_approval: 'Request Pending Approval',
request_approved: 'Request Approved',
request_available: 'Audiobook Available',
request_error: 'Request Error',
};
const eventLabels: Record<string, string> = EVENT_LABELS;
export function NotificationsTab() {
const [backends, setBackends] = useState<NotificationBackend[]>([]);
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getNotificationService } from '@/lib/services/notification';
import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
@@ -15,7 +16,7 @@ const logger = RMABLogger.create('API.Admin.Notifications.Id');
const UpdateBackendSchema = z.object({
name: z.string().min(1).optional(),
config: z.record(z.any()).optional(),
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1).optional(),
events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1).optional(),
enabled: z.boolean().optional(),
});
+2 -1
View File
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getNotificationService, getRegisteredProviderTypes } from '@/lib/services/notification';
import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
@@ -16,7 +17,7 @@ const CreateBackendSchema = z.object({
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }),
name: z.string().min(1),
config: z.record(z.any()),
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1),
events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1),
enabled: z.boolean().default(true),
});
@@ -0,0 +1,87 @@
/**
* Component: Admin Replace Audiobook API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { replaceAudiobook, ReportedIssueError } from '@/lib/services/reported-issue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.ReportedIssues.Replace');
const ReplaceSchema = z.object({
torrent: z.object({
guid: z.string(),
title: z.string(),
size: z.number(),
seeders: z.number().optional(),
leechers: z.number().optional(),
indexer: z.string(),
indexerId: z.number().optional(),
downloadUrl: z.string(),
infoUrl: z.string().optional(),
publishDate: z.string().transform((str) => new Date(str)),
infoHash: z.string().optional(),
format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(),
bitrate: z.string().optional(),
hasChapters: z.boolean().optional(),
protocol: z.enum(['torrent', 'usenet']).optional(),
}),
});
/**
* POST /api/admin/reported-issues/[id]/replace
* Atomically replace audiobook content: delete old → create new request → start download → resolve issue
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const body = await req.json();
const { torrent } = ReplaceSchema.parse(body);
const result = await replaceAudiobook(id, req.user.id, torrent);
return NextResponse.json({
success: true,
request: result.request,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
if (error instanceof ReportedIssueError) {
return NextResponse.json(
{ error: 'ReplaceError', message: error.message },
{ status: error.statusCode }
);
}
logger.error('Failed to replace audiobook', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to replace audiobook' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,74 @@
/**
* Component: Admin Resolve Reported Issue API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { dismissIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.ReportedIssues.Resolve');
const ResolveSchema = z.object({
action: z.enum(['dismiss']),
});
/**
* POST /api/admin/reported-issues/[id]/resolve
* Dismiss a reported issue
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const body = await req.json();
const { action } = ResolveSchema.parse(body);
if (action === 'dismiss') {
const issue = await dismissIssue(id, req.user.id);
return NextResponse.json({ success: true, issue });
}
return NextResponse.json(
{ error: 'InvalidAction', message: 'Unknown action' },
{ status: 400 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
if (error instanceof ReportedIssueError) {
return NextResponse.json(
{ error: 'ResolveError', message: error.message },
{ status: error.statusCode }
);
}
logger.error('Failed to resolve issue', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to resolve issue' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,39 @@
/**
* Component: Admin Reported Issues List API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getOpenIssues } from '@/lib/services/reported-issue.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.ReportedIssues');
/**
* GET /api/admin/reported-issues
* Get all open reported issues with audiobook metadata and reporter info
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const issues = await getOpenIssues();
return NextResponse.json({
success: true,
issues,
count: issues.length,
});
} catch (error) {
logger.error('Failed to fetch reported issues', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to fetch reported issues' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,69 @@
/**
* Component: Report Issue API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { reportIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.ReportIssue');
const ReportIssueSchema = z.object({
reason: z.string().min(1, 'Reason is required').max(250, 'Reason must be 250 characters or less'),
title: z.string().optional(),
author: z.string().optional(),
coverArtUrl: z.string().optional(),
});
/**
* POST /api/audiobooks/[asin]/report-issue
* Report an issue with an available audiobook
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { asin } = await params;
const body = await req.json();
const { reason, title, author, coverArtUrl } = ReportIssueSchema.parse(body);
const issue = await reportIssue(asin, req.user.id, reason, { title, author, coverArtUrl });
return NextResponse.json({ success: true, issue }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
if (error instanceof ReportedIssueError) {
return NextResponse.json(
{ error: 'ReportIssueError', message: error.message },
{ status: error.statusCode }
);
}
logger.error('Failed to report issue', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to report issue' },
{ status: 500 }
);
}
});
}
+17 -259
View File
@@ -6,11 +6,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
import { createRequestForUser } from '@/lib/services/request-creator.service';
const logger = RMABLogger.create('API.Requests');
@@ -45,274 +43,34 @@ export async function POST(request: NextRequest) {
const body = await req.json();
const { audiobook } = CreateRequestSchema.parse(body);
// First check: Is there an existing audiobook request in 'downloaded' or 'available' status?
// This catches the gap where files are organized but Plex hasn't scanned yet
const existingActiveRequest = await prisma.request.findFirst({
where: {
audiobook: {
audibleAsin: audiobook.asin,
},
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
include: {
user: { select: { plexUsername: true } },
},
});
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
if (existingActiveRequest) {
const status = existingActiveRequest.status;
const isOwnRequest = existingActiveRequest.userId === req.user.id;
return NextResponse.json(
{
error: status === 'available' ? 'AlreadyAvailable' : 'BeingProcessed',
message: status === 'available'
? 'This audiobook is already available in your Plex library'
: 'This audiobook is being processed and will be available soon',
requestStatus: status,
isOwnRequest,
requestedBy: existingActiveRequest.user?.plexUsername,
},
{ status: 409 }
);
}
// Second check: Is audiobook already in Plex library? (fallback for non-requested books)
const plexMatch = await findPlexMatch({
const result = await createRequestForUser(req.user.id, {
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
});
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
}, { skipAutoSearch });
if (plexMatch) {
if (!result.success) {
const statusMap: Record<string, { error: string; status: number }> = {
already_available: { error: 'AlreadyAvailable', status: 409 },
being_processed: { error: 'BeingProcessed', status: 409 },
duplicate: { error: 'DuplicateRequest', status: 409 },
user_not_found: { error: 'UserNotFound', status: 404 },
};
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
return NextResponse.json(
{
error: 'AlreadyAvailable',
message: 'This audiobook is already available in your Plex library',
plexGuid: plexMatch.plexGuid,
},
{ status: 409 }
{ error: mapped.error, message: result.message },
{ status: mapped.status }
);
}
// Fetch full details from Audnexus to get releaseDate, year, and series
let year: number | undefined;
let series: string | undefined;
let seriesPart: string | undefined;
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
if (audnexusData?.releaseDate) {
try {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
}
} catch (error) {
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Extract series data
if (audnexusData?.series) {
series = audnexusData.series;
logger.debug(`Extracted series: ${series}`);
}
if (audnexusData?.seriesPart) {
seriesPart = audnexusData.seriesPart;
logger.debug(`Extracted seriesPart: ${seriesPart}`);
}
} catch (error) {
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Try to find existing audiobook record by ASIN
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
});
// If not found, create new audiobook record
if (!audiobookRecord) {
audiobookRecord = await prisma.audiobook.create({
data: {
audibleAsin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
year,
series,
seriesPart,
status: 'requested',
},
});
logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}, series: ${series || 'none'}`);
} else if (year || series || seriesPart) {
// Always update year/series if we have them from Audnexus (even if audiobook already has them)
audiobookRecord = await prisma.audiobook.update({
where: { id: audiobookRecord.id },
data: {
...(year && { year }),
...(series && { series }),
...(seriesPart && { seriesPart }),
},
});
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
}
// Check if user already has an active (non-deleted) audiobook request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
deletedAt: null, // Only check active requests
},
});
if (existingRequest) {
// Allow re-requesting if the status is failed, warn, or cancelled
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
if (!canReRequest) {
return NextResponse.json(
{
error: 'DuplicateRequest',
message: 'You have already requested this audiobook',
request: existingRequest,
},
{ status: 409 }
);
}
// Delete the existing failed/warn/cancelled request
logger.debug(`Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
await prisma.request.delete({
where: { id: existingRequest.id },
});
}
// Check if we should skip auto-search (for interactive search)
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
// Check if request needs approval
let needsApproval = false;
let shouldTriggerSearch = !skipAutoSearch;
// Fetch user with autoApproveRequests setting
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: {
role: true,
autoApproveRequests: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'UserNotFound', message: 'User not found' },
{ status: 404 }
);
}
// Determine if approval is needed
if (user.role === 'admin') {
// Admins always auto-approve
needsApproval = false;
} else {
// Check user's personal setting first
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
// User setting is null, check global setting
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
// Default to true if not configured (backward compatibility)
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
// Determine initial status
let initialStatus: string;
if (needsApproval) {
initialStatus = 'awaiting_approval';
shouldTriggerSearch = false; // Don't trigger search if awaiting approval
} else if (skipAutoSearch) {
initialStatus = 'awaiting_search';
} else {
initialStatus = 'pending';
}
// Create request with appropriate status
const newRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: initialStatus,
type: 'audiobook', // Explicit type for user-created requests
progress: 0,
},
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
});
const jobQueue = getJobQueueService();
// Send notification based on approval status
if (initialStatus === 'awaiting_approval') {
// Request needs approval - send pending notification
await jobQueue.addNotificationJob(
'request_pending_approval',
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
newRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
} else {
// Request was auto-approved (either automatic or interactive search) - send approved notification
await jobQueue.addNotificationJob(
'request_approved',
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
newRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
}
// Trigger search job only if not skipped and not awaiting approval
if (shouldTriggerSearch) {
await jobQueue.addSearchJob(newRequest.id, {
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
asin: audiobookRecord.audibleAsin || undefined,
});
}
return NextResponse.json({
success: true,
request: newRequest,
request: result.request,
}, { status: 201 });
} catch (error) {
logger.error('Failed to create request', { error: error instanceof Error ? error.message : String(error) });
@@ -0,0 +1,50 @@
/**
* Component: Goodreads Shelf Delete Route
* Documentation: documentation/backend/services/goodreads-sync.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.GoodreadsShelves');
/**
* DELETE /api/user/goodreads-shelves/[id]
* Remove a Goodreads shelf subscription (ownership check)
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const shelf = await prisma.goodreadsShelf.findUnique({
where: { id },
});
if (!shelf) {
return NextResponse.json({ error: 'Shelf not found' }, { status: 404 });
}
// Ownership check
if (shelf.userId !== req.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await prisma.goodreadsShelf.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Failed to delete shelf', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Failed to delete shelf' }, { status: 500 });
}
});
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Component: Goodreads Shelves API Routes
* Documentation: documentation/backend/services/goodreads-sync.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { fetchAndValidateRss } from '@/lib/services/goodreads-sync.service';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.GoodreadsShelves');
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
const AddShelfSchema = z.object({
rssUrl: z.string().url().refine(
(url) => GOODREADS_RSS_PATTERN.test(url),
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
),
});
/**
* GET /api/user/goodreads-shelves
* List the current user's Goodreads shelves with book counts and covers
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const shelves = await prisma.goodreadsShelf.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
const shelvesWithMeta = shelves.map((shelf) => {
// Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[])
let books: { coverUrl: string; asin: string | null; title: string; author: string }[] = [];
if (shelf.coverUrls) {
const parsed = JSON.parse(shelf.coverUrls);
if (Array.isArray(parsed)) {
books = parsed.map((item: unknown) => {
if (typeof item === 'string') {
return { coverUrl: item, asin: null, title: '', author: '' };
}
const obj = item as Record<string, unknown>;
return {
coverUrl: (obj.coverUrl as string) || '',
asin: (obj.asin as string) || null,
title: (obj.title as string) || '',
author: (obj.author as string) || '',
};
});
}
}
return {
id: shelf.id,
name: shelf.name,
rssUrl: shelf.rssUrl,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount ?? null,
books,
};
});
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
} catch (error) {
logger.error('Failed to list shelves', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Failed to list shelves' }, { status: 500 });
}
});
}
/**
* POST /api/user/goodreads-shelves
* Add a new Goodreads shelf subscription
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { rssUrl } = AddShelfSchema.parse(body);
// Check for duplicate
const existing = await prisma.goodreadsShelf.findUnique({
where: { userId_rssUrl: { userId: req.user.id, rssUrl } },
});
if (existing) {
return NextResponse.json(
{ error: 'DuplicateShelf', message: 'You have already added this shelf' },
{ status: 409 }
);
}
// Validate by fetching the RSS feed
let shelfName: string;
let bookCount: number;
let initialBooks: { coverUrl: string; asin: null; title: string; author: string }[] = [];
try {
const rssData = await fetchAndValidateRss(rssUrl);
shelfName = rssData.shelfName;
bookCount = rssData.books.length;
initialBooks = rssData.books
.filter(b => b.coverUrl)
.slice(0, 8)
.map(b => ({ coverUrl: b.coverUrl!, asin: null, title: b.title, author: b.author }));
} catch (error) {
return NextResponse.json(
{
error: 'InvalidRSS',
message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 400 }
);
}
const shelf = await prisma.goodreadsShelf.create({
data: {
userId: req.user.id,
name: shelfName,
rssUrl,
bookCount,
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
},
});
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
} catch (error) {
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
}
return NextResponse.json({
success: true,
shelf: {
id: shelf.id,
name: shelf.name,
rssUrl: shelf.rssUrl,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount,
books: initialBooks,
},
bookCount,
}, { status: 201 });
} catch (error) {
logger.error('Failed to add shelf', { error: error instanceof Error ? error.message : String(error) });
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json({ error: 'Failed to add shelf' }, { status: 500 });
}
});
}
+9
View File
@@ -196,3 +196,12 @@ body {
.animate-toast-in {
animation: toast-slide-in 0.3s ease-out;
}
/* Hide scrollbar while keeping scroll functional */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
+124 -252
View File
@@ -11,80 +11,63 @@ import { RequestCard } from '@/components/requests/RequestCard';
import { useAuth } from '@/contexts/AuthContext';
import { useRequests } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
const statConfig = [
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
{ key: 'active', label: 'Active', color: 'text-blue-500' },
{ key: 'waiting', label: 'Waiting', color: 'text-amber-500' },
{ key: 'completed', label: 'Complete', color: 'text-emerald-500' },
{ key: 'failed', label: 'Failed', color: 'text-red-500' },
{ key: 'cancelled', label: 'Cancelled', color: 'text-gray-400 dark:text-gray-500' },
] as const;
type StatKey = (typeof statConfig)[number]['key'];
export default function ProfilePage() {
const { user } = useAuth();
// Always show only the current user's own requests (even for admins)
const { requests, isLoading } = useRequests(undefined, 50, true);
// Calculate statistics
const stats = useMemo(() => {
if (!requests.length) {
return {
total: 0,
completed: 0,
active: 0,
waiting: 0,
failed: 0,
cancelled: 0,
};
return { total: 0, completed: 0, active: 0, waiting: 0, failed: 0, cancelled: 0 };
}
return {
total: requests.length,
completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length,
active: requests.filter((r: any) =>
['pending', 'searching', 'downloading', 'processing'].includes(r.status)
).length,
waiting: requests.filter((r: any) =>
['awaiting_search', 'awaiting_import'].includes(r.status)
).length,
active: requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length,
waiting: requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length,
failed: requests.filter((r: any) => r.status === 'failed').length,
cancelled: requests.filter((r: any) => r.status === 'cancelled').length,
};
}, [requests]);
// Get active downloads (downloading or processing)
const activeDownloads = useMemo(() => {
return requests.filter((r: any) =>
['downloading', 'processing'].includes(r.status)
);
return requests.filter((r: any) => ['downloading', 'processing'].includes(r.status));
}, [requests]);
// Get recent requests (last 5)
const recentRequests = useMemo(() => {
return [...requests]
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5);
}, [requests]);
// Redirect to login if not authenticated
if (!user) {
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="text-center py-16 space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
<main className="container mx-auto px-4 py-20 max-w-5xl text-center">
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Authentication Required
</h2>
<p className="text-gray-600 dark:text-gray-400">
Please log in to view your profile
</p>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Sign in required
</h2>
<p className="text-gray-500 dark:text-gray-400">
Please log in to view your profile
</p>
</main>
</div>
);
@@ -94,183 +77,83 @@ export default function ProfilePage() {
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
{/* User Info Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
<main className="container mx-auto px-4 py-8 max-w-5xl space-y-10">
{/* Profile Card — gradient banner + avatar + info + stats */}
<section className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
{/* Gradient Banner */}
<div className="h-32 sm:h-40 bg-gradient-to-br from-blue-600 via-indigo-500 to-violet-600" />
{/* Profile Content — overlapping the banner */}
<div className="px-6 sm:px-8 pb-8 -mt-14 sm:-mt-16">
{/* Avatar */}
<div className="flex-shrink-0">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.username}
className="w-24 h-24 rounded-full"
/>
) : (
<div className="w-24 h-24 rounded-full bg-blue-600 flex items-center justify-center text-white text-3xl font-bold">
{user.username.charAt(0).toUpperCase()}
</div>
)}
</div>
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.username}
className="w-28 h-28 rounded-full ring-4 ring-white dark:ring-gray-800 shadow-lg object-cover mb-5"
/>
) : (
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-4xl font-bold ring-4 ring-white dark:ring-gray-800 shadow-lg mb-5">
{user.username.charAt(0).toUpperCase()}
</div>
)}
{/* User Details */}
<div className="flex-1 space-y-2 text-center sm:text-left">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
{user.username}
</h1>
{user.email && (
<p className="text-gray-600 dark:text-gray-400">
{user.email}
</p>
)}
<div className="flex items-center gap-2">
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
user.role === 'admin'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
)}
>
{user.role === 'admin' ? 'Administrator' : 'User'}
</span>
<span className="text-sm text-gray-500 dark:text-gray-500">
Plex ID: {user.plexId}
</span>
</div>
</div>
</div>
</div>
{/* Statistics Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
{/* Total Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Total</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
{isLoading ? '...' : stats.total}
</p>
</div>
{/* Name + Email + Badge */}
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{user.username}
</h1>
{user.email && (
<p className="text-base text-gray-500 dark:text-gray-400 mt-1">
{user.email}
</p>
)}
<div className="mt-3">
<span
className={cn(
'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wide',
user.role === 'admin'
? 'bg-purple-50 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700/50 dark:text-gray-400'
)}
>
{user.role === 'admin' ? 'Administrator' : 'User'}
</span>
</div>
</div>
{/* Active Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{/* Stats Strip */}
<div className="grid grid-cols-3 sm:grid-cols-6 gap-px bg-gray-100 dark:bg-gray-700/30">
{statConfig.map((stat) => (
<div
key={stat.key}
className="py-5 sm:py-6 px-3 text-center bg-white dark:bg-gray-800"
>
<div className={cn('text-2xl sm:text-3xl font-bold tabular-nums', stat.color)}>
{isLoading ? '\u2013' : stats[stat.key as StatKey]}
</div>
<div className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mt-1.5">
{stat.label}
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Active</p>
<p className="text-xl sm:text-2xl font-bold text-blue-600 dark:text-blue-400">
{isLoading ? '...' : stats.active}
</p>
</div>
</div>
))}
</div>
</section>
{/* Waiting Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Waiting</p>
<p className="text-xl sm:text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{isLoading ? '...' : stats.waiting}
</p>
</div>
</div>
</div>
{/* Completed Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" 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>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Completed</p>
<p className="text-xl sm:text-2xl font-bold text-green-600 dark:text-green-400">
{isLoading ? '...' : stats.completed}
</p>
</div>
</div>
</div>
{/* Failed Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-red-600 dark:text-red-400" 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>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Failed</p>
<p className="text-xl sm:text-2xl font-bold text-red-600 dark:text-red-400">
{isLoading ? '...' : stats.failed}
</p>
</div>
</div>
</div>
{/* Cancelled Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<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>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Cancelled</p>
<p className="text-xl sm:text-2xl font-bold text-gray-600 dark:text-gray-400">
{isLoading ? '...' : stats.cancelled}
</p>
</div>
</div>
</div>
</div>
{/* Goodreads Shelves */}
<GoodreadsShelvesSection />
{/* Active Downloads */}
{activeDownloads.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<section>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Active Downloads
</h2>
<a
href="/requests"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
className="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
View All Requests
View All
</a>
</div>
<div className="space-y-4">
@@ -278,21 +161,23 @@ export default function ProfilePage() {
<RequestCard key={request.id} request={request} showActions={false} />
))}
</div>
</div>
</section>
)}
{/* Recent Requests */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<section>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Recent Requests
</h2>
<a
href="/requests"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View All Requests
</a>
{requests.length > 0 && (
<a
href="/requests"
className="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
View All
</a>
)}
</div>
{isLoading ? (
@@ -300,14 +185,14 @@ export default function ProfilePage() {
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-5 animate-pulse"
>
<div className="flex gap-4">
<div className="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div>
<div className="flex-1 space-y-3">
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-24"></div>
<div className="w-20 h-28 bg-gray-100 dark:bg-gray-700/50 rounded-lg flex-shrink-0" />
<div className="flex-1 space-y-3 py-1">
<div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-3/4" />
<div className="h-4 bg-gray-100 dark:bg-gray-700/50 rounded w-1/2" />
<div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-24" />
</div>
</div>
</div>
@@ -320,47 +205,34 @@ export default function ProfilePage() {
))}
</div>
) : (
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow-md space-y-4">
<div className="rounded-2xl border-2 border-dashed border-gray-200 dark:border-gray-700/50 py-16 text-center">
<svg
className="mx-auto h-16 w-16 text-gray-400"
className="mx-auto w-10 h-10 text-gray-300 dark:text-gray-600 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
</svg>
<div className="space-y-2">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
No requests yet
</h3>
<p className="text-gray-600 dark:text-gray-400">
Start by searching for audiobooks and requesting them
</p>
</div>
<div className="pt-4">
<a
href="/search"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<svg className="w-5 h-5" 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>
Search Audiobooks
</a>
</div>
<p className="text-base font-medium text-gray-500 dark:text-gray-400">
No requests yet
</p>
<p className="text-sm text-gray-400 dark:text-gray-600 mt-1">
Search for audiobooks to get started
</p>
<a
href="/search"
className="inline-flex items-center gap-2 mt-5 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
Search Audiobooks
</a>
</div>
)}
</div>
</section>
</main>
</div>
);