mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Add admin request deletion with soft delete and cleanup
Implements admin ability to delete requests with soft delete, media file cleanup, and seeding-aware torrent management. Adds new API endpoint, frontend confirmation dialog, and request actions dropdown. Updates database schema with deletedAt and deletedBy fields, and ensures all queries filter out deleted requests. Documentation added for feature and user flow.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Component: Confirm Dialog
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Reusable confirmation dialog for destructive actions
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string | React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmVariant?: 'danger' | 'primary';
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
confirmVariant = 'danger',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const confirmButtonClasses =
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-red-100 dark:bg-red-900'
|
||||
: 'bg-blue-100 dark:bg-blue-900'
|
||||
} sm:mx-0 sm:h-10 sm:w-10`}
|
||||
>
|
||||
<svg
|
||||
className={`h-6 w-6 ${
|
||||
confirmVariant === 'danger'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{confirmVariant === 'danger' ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1">
|
||||
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`inline-flex w-full justify-center rounded-lg px-4 py-2 text-sm font-semibold shadow-sm sm:w-auto transition-colors ${confirmButtonClasses}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="mt-3 inline-flex w-full justify-center rounded-lg bg-white dark:bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { RequestActionsDropdown } from './RequestActionsDropdown';
|
||||
import { mutate } from 'swr';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
interface RecentRequest {
|
||||
requestId: string;
|
||||
@@ -57,6 +62,120 @@ function getStatusBadge(status: string) {
|
||||
}
|
||||
|
||||
export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [selectedRequest, setSelectedRequest] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDeleteClick = (requestId: string, title: string) => {
|
||||
setSelectedRequest({ id: requestId, title });
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!selectedRequest) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/admin/requests/${selectedRequest.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to delete request');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Show success message
|
||||
console.log('[Admin] Request deleted:', result);
|
||||
|
||||
// Refresh the requests list
|
||||
await mutate('/api/admin/requests/recent');
|
||||
await mutate('/api/admin/metrics');
|
||||
|
||||
// Close dialog
|
||||
setShowDeleteConfirm(false);
|
||||
setSelectedRequest(null);
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to delete request:', error);
|
||||
alert(
|
||||
`Failed to delete request: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
setSelectedRequest(null);
|
||||
};
|
||||
|
||||
const handleManualSearch = async (requestId: string) => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/requests/${requestId}/manual-search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to trigger manual search');
|
||||
}
|
||||
|
||||
console.log('[Admin] Manual search triggered for request:', requestId);
|
||||
// Refresh the requests list
|
||||
await mutate('/api/admin/requests/recent');
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to trigger manual search:', error);
|
||||
alert(
|
||||
`Failed to trigger manual search: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (requestId: string) => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/requests/${requestId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ action: 'cancel' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to cancel request');
|
||||
}
|
||||
|
||||
console.log('[Admin] Request cancelled:', requestId);
|
||||
// Refresh the requests list
|
||||
await mutate('/api/admin/requests/recent');
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to cancel request:', error);
|
||||
alert(
|
||||
`Failed to cancel request: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8">
|
||||
@@ -107,6 +226,9 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Completed
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@@ -144,11 +266,53 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
})
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<RequestActionsDropdown
|
||||
request={{
|
||||
requestId: request.requestId,
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
status: request.status,
|
||||
}}
|
||||
onDelete={handleDeleteClick}
|
||||
onManualSearch={handleManualSearch}
|
||||
onCancel={handleCancel}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
title="Delete Request?"
|
||||
message={
|
||||
selectedRequest ? (
|
||||
<div>
|
||||
<p className="mb-3">
|
||||
This will delete the request for "{selectedRequest.title}" and:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
<li>Remove the request (allowing it to be re-requested)</li>
|
||||
<li>Delete files from the media directory</li>
|
||||
<li>Keep torrent seeding if time remaining</li>
|
||||
</ul>
|
||||
<p className="mt-3 font-semibold">Are you sure?</p>
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
confirmLabel={isDeleting ? 'Deleting...' : 'Delete'}
|
||||
cancelLabel="Cancel"
|
||||
confirmVariant="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Component: Request Actions Dropdown
|
||||
* Documentation: documentation/admin-features/request-deletion.md
|
||||
*
|
||||
* Dropdown menu for admin actions on requests
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
|
||||
export interface RequestActionsDropdownProps {
|
||||
request: {
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
status: string;
|
||||
};
|
||||
onDelete: (requestId: string, title: string) => void;
|
||||
onManualSearch: (requestId: string) => Promise<void>;
|
||||
onCancel: (requestId: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function RequestActionsDropdown({
|
||||
request,
|
||||
onDelete,
|
||||
onManualSearch,
|
||||
onCancel,
|
||||
isLoading = false,
|
||||
}: RequestActionsDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Determine available actions based on status
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleManualSearch = async () => {
|
||||
setIsOpen(false);
|
||||
try {
|
||||
await onManualSearch(request.requestId);
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger manual search:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInteractiveSearch = () => {
|
||||
setIsOpen(false);
|
||||
setShowInteractiveSearch(true);
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsOpen(false);
|
||||
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
||||
try {
|
||||
await onCancel(request.requestId);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel request:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setIsOpen(false);
|
||||
onDelete(request.requestId, request.title);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Three-dot menu button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Actions"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-600 dark:text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-56 rounded-lg shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50">
|
||||
<div className="py-1" role="menu">
|
||||
{/* Manual Search */}
|
||||
{canSearch && (
|
||||
<button
|
||||
onClick={handleManualSearch}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<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>
|
||||
Manual Search
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Interactive Search */}
|
||||
{canSearch && (
|
||||
<button
|
||||
onClick={handleInteractiveSearch}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
Interactive Search
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search actions and other actions */}
|
||||
{canSearch && (canCancel || canDelete) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
{/* Cancel */}
|
||||
{canCancel && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="w-full text-left px-4 py-2 text-sm text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<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>
|
||||
Cancel Request
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider before delete */}
|
||||
{canDelete && (canSearch || canCancel) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Delete Request
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interactive Search Modal */}
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={showInteractiveSearch}
|
||||
onClose={() => setShowInteractiveSearch(false)}
|
||||
requestId={request.requestId}
|
||||
audiobook={{
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user