From c3bede86febb1bad5a93101f3641c46c76226ef9 Mon Sep 17 00:00:00 2001 From: averel10 Date: Fri, 20 Mar 2026 08:39:19 +0100 Subject: [PATCH] added download list --- src/app/actions/get-available-downloads.ts | 63 ++++++++++++ src/app/admin/datasets/[id]/page.tsx | 2 + src/components/DatasetDownloadsList.tsx | 93 ++++++++++++++++++ src/components/DatasetDownloadsModal.tsx | 106 +++++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 src/app/actions/get-available-downloads.ts create mode 100644 src/components/DatasetDownloadsList.tsx create mode 100644 src/components/DatasetDownloadsModal.tsx diff --git a/src/app/actions/get-available-downloads.ts b/src/app/actions/get-available-downloads.ts new file mode 100644 index 0000000..f096579 --- /dev/null +++ b/src/app/actions/get-available-downloads.ts @@ -0,0 +1,63 @@ +'use server'; + +import { readdirSync, unlinkSync } from 'fs'; +import { join } from 'path'; +import { requireAdmin } from '@/lib/auth'; + +interface DownloadFile { + name: string; + url: string; +} + +export async function getAvailableDownloads(datasetId: number): Promise { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + const downloadsDir = join(process.cwd(), 'public', 'downloads'); + const files = readdirSync(downloadsDir); + + const pattern = new RegExp(`^dataset-${datasetId}-export-`); + const matching = files.filter(file => pattern.test(file) && file.endsWith('.zip')); + + // Sort by timestamp descending (most recent first) + matching.sort().reverse(); + + return matching.map(file => ({ + name: file, + url: `/public/downloads/${file}` + })); + } catch (error) { + console.error('Error fetching available downloads:', error); + throw new Error('Failed to fetch available downloads'); + } +} + +export async function deleteDownload(fileName: string): Promise { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Validate filename to prevent directory traversal attacks + if (fileName.includes('..') || fileName.includes('/') || fileName.includes('\\')) { + throw new Error('Invalid filename'); + } + + const downloadsDir = join(process.cwd(), 'public', 'downloads'); + const filePath = join(downloadsDir, fileName); + + // Ensure the file is within the downloads directory + if (!filePath.startsWith(downloadsDir)) { + throw new Error('Invalid file path'); + } + + unlinkSync(filePath); + } catch (error) { + console.error('Error deleting download:', error); + throw new Error('Failed to delete download'); + } +} diff --git a/src/app/admin/datasets/[id]/page.tsx b/src/app/admin/datasets/[id]/page.tsx index ef61a11..ebd184b 100644 --- a/src/app/admin/datasets/[id]/page.tsx +++ b/src/app/admin/datasets/[id]/page.tsx @@ -4,6 +4,7 @@ import { dataset } from '@/lib/model/dataset'; import { eq } from 'drizzle-orm'; import EditableDatasetHeader from '@/components/EditableDatasetHeader'; import DatasetEntriesList from '@/components/DatasetEntriesList'; +import DatasetDownloadsModal from '@/components/DatasetDownloadsModal'; import UploadDatasetEntriesModal from '@/components/UploadDatasetEntriesModal'; import UpdateDatasetMetadataModal from '@/components/UpdateDatasetMetadataModal'; import RemoveAllEntriesButton from '@/components/RemoveAllEntriesButton'; @@ -71,6 +72,7 @@ export default async function DatasetPage({ params }: DatasetPageProps) {
+
diff --git a/src/components/DatasetDownloadsList.tsx b/src/components/DatasetDownloadsList.tsx new file mode 100644 index 0000000..f98e39e --- /dev/null +++ b/src/components/DatasetDownloadsList.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getAvailableDownloads, deleteDownload } from '@/app/actions/get-available-downloads'; + +interface DownloadFile { + name: string; + url: string; +} + +interface DatasetDownloadsListProps { + datasetId: number; +} + +export default function DatasetDownloadsList({ datasetId }: DatasetDownloadsListProps) { + const [downloads, setDownloads] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [deleting, setDeleting] = useState(null); + + const fetchDownloads = async () => { + try { + setError(null); + const files = await getAvailableDownloads(datasetId); + setDownloads(files); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load downloads'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchDownloads(); + }, [datasetId]); + + const handleDelete = async (fileName: string) => { + if (!confirm(`Are you sure you want to delete "${fileName}"?`)) { + return; + } + + setDeleting(fileName); + try { + await deleteDownload(fileName); + setDownloads(downloads.filter(d => d.name !== fileName)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete download'); + } finally { + setDeleting(null); + } + }; + + if (loading) { + return
Loading available downloads...
; + } + + if (error) { + return
Error: {error}
; + } + + if (downloads.length === 0) { + return
No downloads available for this dataset
; + } + + return ( +
+

Available Downloads

+
+ {downloads.map((download) => ( +
+ {download.name} +
+ + Download + + +
+
+ ))} +
+
+ ); +} diff --git a/src/components/DatasetDownloadsModal.tsx b/src/components/DatasetDownloadsModal.tsx new file mode 100644 index 0000000..1c4e07f --- /dev/null +++ b/src/components/DatasetDownloadsModal.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Modal from './Modal'; +import { getAvailableDownloads, deleteDownload } from '@/app/actions/get-available-downloads'; + +interface DownloadFile { + name: string; + url: string; +} + +interface DatasetDownloadsModalProps { + datasetId: number; +} + +export default function DatasetDownloadsModal({ datasetId }: DatasetDownloadsModalProps) { + const [isOpen, setIsOpen] = useState(false); + const [downloads, setDownloads] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [deleting, setDeleting] = useState(null); + + const fetchDownloads = async () => { + try { + setLoading(true); + setError(null); + const files = await getAvailableDownloads(datasetId); + setDownloads(files); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load downloads'); + } finally { + setLoading(false); + } + }; + + const handleOpenModal = () => { + setIsOpen(true); + if (downloads.length === 0) { + fetchDownloads(); + } + }; + + const handleDelete = async (fileName: string) => { + if (!confirm(`Are you sure you want to delete "${fileName}"?`)) { + return; + } + + setDeleting(fileName); + try { + await deleteDownload(fileName); + setDownloads(downloads.filter(d => d.name !== fileName)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete download'); + } finally { + setDeleting(null); + } + }; + + return ( + <> + + + setIsOpen(false)} title="Available Downloads"> + {loading ? ( +
Loading available downloads...
+ ) : error ? ( +
Error: {error}
+ ) : downloads.length === 0 ? ( +
No downloads available for this dataset
+ ) : ( +
+ {downloads.map((download) => ( +
+ {download.name} +
+ + Download + + +
+
+ ))} +
+ )} +
+ + ); +}