Merge pull request #158 from xFlawless11x/feature/admin-book-info-modal

feat: add book info modal to admin pending approval cards
This commit is contained in:
kikootwo
2026-05-14 15:34:20 -04:00
committed by GitHub
5 changed files with 98 additions and 1 deletions
+1
View File
@@ -144,6 +144,7 @@
**"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md) **"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
**"How do I approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md) **"How do I approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
**"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md) **"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
**"How does the admin book info modal work?"** → [admin-features/request-approval.md](admin-features/request-approval.md#ui-features), [frontend/components.md](frontend/components.md#component-apis)
**"How do I customize audiobook folder organization?"** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure) **"How do I customize audiobook folder organization?"** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure)
**"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one) **"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one)
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md) **"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
@@ -259,8 +259,11 @@ Update user (includes autoApproveRequests field)
- Title and author - Title and author
- User avatar and username - User avatar and username
- Request timestamp (relative: "2 hours ago") - Request timestamp (relative: "2 hours ago")
- Info button (ⓘ, top-right corner) — opens AudiobookDetailsModal for full book details
- Approve button (green, checkmark icon) - Approve button (green, checkmark icon)
- Search button (blue, magnifier icon) — opens InteractiveTorrentSearchModal
- Deny button (red, X icon) - Deny button (red, X icon)
- **Info modal:** `AudiobookDetailsModal` rendered with `adminActions` prop containing Approve/Search/Deny buttons, allowing admin to review full book details (cover, description, series, genres, narrator, etc.) without leaving the approval workflow
- Auto-refreshes every 10 seconds (SWR) - Auto-refreshes every 10 seconds (SWR)
- Loading states on buttons during approval/denial - Loading states on buttons during approval/denial
- Success/error toast notifications - Success/error toast notifications
+1
View File
@@ -113,6 +113,7 @@ interface AudiobookDetailsModalProps {
requestStatus?: string | null; requestStatus?: string | null;
isAvailable?: boolean; isAvailable?: boolean;
requestedByUsername?: string | null; requestedByUsername?: string | null;
adminActions?: React.ReactNode; // Optional admin buttons (Approve/Search/Deny) rendered as second row in action bar
} }
interface RequestCardProps { interface RequestCardProps {
+83 -1
View File
@@ -14,8 +14,10 @@ import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast'; import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ReportedIssuesSection } from './components/ReportedIssuesSection'; import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { BulkImportWizard } from '@/components/admin/BulkImportWizard'; import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
import { TorrentResult } from '@/lib/utils/ranking-algorithm'; import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { InformationCircleIcon } from '@heroicons/react/24/outline';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
@@ -60,11 +62,17 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
const toast = useToast(); const toast = useToast();
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({}); const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null); const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null);
const [detailsAsin, setDetailsAsin] = useState<string | null>(null);
const [detailsRequestId, setDetailsRequestId] = useState<string | null>(null);
const searchModalRequest = searchModalRequestId const searchModalRequest = searchModalRequestId
? requests.find((r) => r.id === searchModalRequestId) ? requests.find((r) => r.id === searchModalRequestId)
: null; : null;
const detailsRequest = detailsRequestId
? requests.find((r) => r.id === detailsRequestId)
: null;
const handleApproveRequest = async (requestId: string) => { const handleApproveRequest = async (requestId: string) => {
setLoadingStates((prev) => ({ ...prev, [requestId]: true })); setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
@@ -170,8 +178,22 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
return ( return (
<div <div
key={request.id} key={request.id}
className="bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden" className="relative bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
> >
{/* Info Button — opens AudiobookDetailsModal */}
{request.audiobook.audibleAsin && (
<button
onClick={() => {
setDetailsAsin(request.audiobook.audibleAsin);
setDetailsRequestId(request.id);
}}
className="absolute top-2 right-2 z-10 p-1 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
title="View book details"
>
<InformationCircleIcon className="w-5 h-5" />
</button>
)}
{/* Card Content */} {/* Card Content */}
<div className="p-4"> <div className="p-4">
<div className="flex gap-3"> <div className="flex gap-3">
@@ -375,6 +397,66 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
}} }}
/> />
)} )}
{/* Book Details Modal — opened via info button on each approval card */}
{detailsAsin && detailsRequestId && (
<AudiobookDetailsModal
asin={detailsAsin}
isOpen={true}
onClose={() => { setDetailsAsin(null); setDetailsRequestId(null); }}
requestStatus="awaiting_approval"
requestedByUsername={detailsRequest?.user.plexUsername ?? null}
adminActions={(() => {
const isLoading = loadingStates[detailsRequestId] || false;
return (
<>
<button
onClick={() => {
handleApproveRequest(detailsRequestId);
setDetailsAsin(null);
setDetailsRequestId(null);
}}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? <LoadingSpinner /> : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
<span>Approve</span>
</button>
<button
onClick={() => setSearchModalRequestId(detailsRequestId)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 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>Search</span>
</button>
<button
onClick={() => {
handleDenyRequest(detailsRequestId);
setDetailsAsin(null);
setDetailsRequestId(null);
}}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? <LoadingSpinner /> : (
<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>Deny</span>
</button>
</>
);
})()}
/>
)}
</div> </div>
); );
} }
@@ -38,6 +38,8 @@ interface AudiobookDetailsModalProps {
hideRequestActions?: boolean; hideRequestActions?: boolean;
hasReportedIssue?: boolean; hasReportedIssue?: boolean;
aiReason?: string | null; aiReason?: string | null;
/** Optional admin action buttons (Approve / Search / Deny) rendered as a second row in the action bar */
adminActions?: React.ReactNode;
} }
// Status helper // Status helper
@@ -80,6 +82,7 @@ export function AudiobookDetailsModal({
hideRequestActions = false, hideRequestActions = false,
hasReportedIssue = false, hasReportedIssue = false,
aiReason = null, aiReason = null,
adminActions,
}: AudiobookDetailsModalProps) { }: AudiobookDetailsModalProps) {
const { user } = useAuth(); const { user } = useAuth();
const { squareCovers } = usePreferences(); const { squareCovers } = usePreferences();
@@ -763,6 +766,13 @@ export function AudiobookDetailsModal({
)} )}
</div> </div>
{/* Admin Actions Row (Approve / Search / Deny) — injected by admin pages */}
{adminActions && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-amber-200 dark:border-amber-700/50">
{adminActions}
</div>
)}
</div> </div>
)} )}