mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user