720 lines
29 KiB
TypeScript
720 lines
29 KiB
TypeScript
import { useEffect, useState, useMemo, useCallback } from "react";
|
|
import apiClient from "../lib/api-client";
|
|
import { toast } from "sonner";
|
|
import {
|
|
createColumnHelper,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
useReactTable,
|
|
getSortedRowModel,
|
|
type SortingState,
|
|
} from "@tanstack/react-table";
|
|
|
|
// --- Type Definitions ---
|
|
type HistoryEntry = {
|
|
id: number;
|
|
download_type: "track" | "album" | "playlist";
|
|
title: string;
|
|
artists: string[];
|
|
timestamp: number;
|
|
status: "completed" | "failed" | "skipped" | "in_progress" | "partial";
|
|
service: string;
|
|
quality_format?: string;
|
|
quality_bitrate?: string;
|
|
total_tracks?: number;
|
|
successful_tracks?: number;
|
|
failed_tracks?: number;
|
|
skipped_tracks?: number;
|
|
children_table?: string;
|
|
task_id: string;
|
|
external_ids: Record<string, any>;
|
|
metadata: Record<string, any>;
|
|
release_date?: Record<string, any>;
|
|
genres: string[];
|
|
images: Array<Record<string, any>>;
|
|
owner?: Record<string, any>;
|
|
album_type?: string;
|
|
duration_total_ms?: number;
|
|
explicit?: boolean;
|
|
};
|
|
|
|
type ChildTrack = {
|
|
id: number;
|
|
title: string;
|
|
artists: string[];
|
|
album_title?: string;
|
|
duration_ms?: number;
|
|
track_number?: number;
|
|
disc_number?: number;
|
|
explicit?: boolean;
|
|
status: "completed" | "failed" | "skipped";
|
|
external_ids: Record<string, any>;
|
|
genres: string[];
|
|
isrc?: string;
|
|
timestamp: number;
|
|
position?: number;
|
|
metadata: Record<string, any>;
|
|
};
|
|
|
|
type ChildrenResponse = {
|
|
task_id: string;
|
|
download_type: string;
|
|
title: string;
|
|
children_table: string;
|
|
tracks: ChildTrack[];
|
|
track_count: number;
|
|
};
|
|
|
|
const STATUS_CLASS: Record<string, string> = {
|
|
completed: "text-success",
|
|
partial: "text-warning",
|
|
failed: "text-error",
|
|
in_progress: "text-warning",
|
|
skipped: "text-content-muted dark:text-content-muted-dark",
|
|
};
|
|
|
|
const formatQuality = (entry: HistoryEntry): string => {
|
|
const format = entry.quality_format || "Unknown";
|
|
const bitrate = entry.quality_bitrate || "";
|
|
return bitrate ? `${format} ${bitrate}` : format;
|
|
};
|
|
|
|
|
|
|
|
const formatDuration = (ms?: number): string => {
|
|
if (!ms) return "N/A";
|
|
const seconds = Math.floor(ms / 1000);
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const secs = seconds % 60;
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
// --- Column Definitions ---
|
|
const columnHelper = createColumnHelper<HistoryEntry | ChildTrack>();
|
|
|
|
export const History = () => {
|
|
const [data, setData] = useState<(HistoryEntry | ChildTrack)[]>([]);
|
|
const [totalEntries, setTotalEntries] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [selectedEntry, setSelectedEntry] = useState<HistoryEntry | null>(null);
|
|
const [viewingChildren, setViewingChildren] = useState<ChildrenResponse | null>(null);
|
|
|
|
// State for TanStack Table
|
|
const [sorting, setSorting] = useState<SortingState>([{ id: "timestamp", desc: true }]);
|
|
const [{ pageIndex, pageSize }, setPagination] = useState({
|
|
pageIndex: 0,
|
|
pageSize: 25,
|
|
});
|
|
|
|
// State for filters
|
|
const [statusFilter, setStatusFilter] = useState("");
|
|
const [typeFilter, setTypeFilter] = useState("");
|
|
|
|
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
|
|
|
|
const viewChildren = useCallback(
|
|
async (parentEntry: HistoryEntry) => {
|
|
if (!parentEntry.children_table) {
|
|
toast.error("This download has no child tracks.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
const response = await apiClient.get<ChildrenResponse>(`/history/${parentEntry.task_id}/children`);
|
|
setViewingChildren(response.data);
|
|
setData(response.data.tracks);
|
|
setTotalEntries(response.data.track_count);
|
|
setPagination({ pageIndex: 0, pageSize });
|
|
} catch (error) {
|
|
toast.error("Failed to load child tracks.");
|
|
console.error("Error loading children:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[pageSize],
|
|
);
|
|
|
|
const viewEntryDetails = useCallback(
|
|
async (taskId: string) => {
|
|
try {
|
|
const response = await apiClient.get<HistoryEntry>(`/history/${taskId}`);
|
|
setSelectedEntry(response.data);
|
|
} catch (error) {
|
|
toast.error("Failed to load entry details.");
|
|
console.error("Error loading entry details:", error);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const columns = useMemo(
|
|
() => [
|
|
columnHelper.accessor("title", {
|
|
header: "Title",
|
|
cell: (info) => {
|
|
const entry = info.row.original;
|
|
const isChild = "album_title" in entry;
|
|
return isChild ? (
|
|
<span className="pl-4 text-muted-foreground">└─ {entry.title}</span>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold">{entry.title}</span>
|
|
{(entry as HistoryEntry).children_table && (
|
|
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
|
|
{(entry as HistoryEntry).total_tracks || "?"} tracks
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
}),
|
|
columnHelper.accessor("artists", {
|
|
header: "Artists",
|
|
cell: (info) => {
|
|
const artists = info.getValue();
|
|
return Array.isArray(artists) ? artists.join(", ") : artists || "Unknown Artist";
|
|
},
|
|
}),
|
|
columnHelper.display({
|
|
id: "type",
|
|
header: "Type",
|
|
cell: (info) => {
|
|
const entry = info.row.original;
|
|
const type = "download_type" in entry ? entry.download_type : "track";
|
|
return <span className="capitalize">{type}</span>;
|
|
},
|
|
}),
|
|
columnHelper.display({
|
|
id: "quality",
|
|
header: "Quality",
|
|
cell: (info) => {
|
|
const entry = info.row.original;
|
|
if ("download_type" in entry) {
|
|
return formatQuality(entry);
|
|
}
|
|
return "N/A";
|
|
},
|
|
}),
|
|
columnHelper.accessor("status", {
|
|
header: "Status",
|
|
cell: (info) => {
|
|
const status = info.getValue();
|
|
const statusClass = STATUS_CLASS[status] || "text-gray-500";
|
|
return <span className={`font-semibold ${statusClass} capitalize`}>{status}</span>;
|
|
},
|
|
}),
|
|
columnHelper.display({
|
|
id: "service",
|
|
header: "Service",
|
|
cell: (info) => {
|
|
const entry = info.row.original;
|
|
const service = "service" in entry ? entry.service : "Unknown";
|
|
return <span className="capitalize">{service}</span>;
|
|
},
|
|
}),
|
|
columnHelper.accessor("timestamp", {
|
|
header: "Date",
|
|
cell: (info) => {
|
|
const timestamp = info.getValue();
|
|
return timestamp ? new Date(timestamp * 1000).toLocaleString() : "N/A";
|
|
},
|
|
}),
|
|
...(!viewingChildren
|
|
? [
|
|
columnHelper.display({
|
|
id: "actions",
|
|
header: "Actions",
|
|
cell: ({ row }) => {
|
|
const entry = row.original as HistoryEntry;
|
|
const hasChildren = entry.children_table &&
|
|
(entry.download_type === "album" || entry.download_type === "playlist");
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => viewEntryDetails(entry.task_id)}
|
|
className="px-2 py-1 text-xs rounded-md bg-gray-600 text-white hover:bg-gray-700"
|
|
>
|
|
Details
|
|
</button>
|
|
{hasChildren && (
|
|
<>
|
|
<button
|
|
onClick={() => viewChildren(entry)}
|
|
className="px-2 py-1 text-xs rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
|
>
|
|
View Tracks
|
|
</button>
|
|
<span className="text-xs">
|
|
<span className="text-green-500">
|
|
{entry.successful_tracks || 0}
|
|
</span> /{" "}
|
|
<span className="text-yellow-500">
|
|
{entry.skipped_tracks || 0}
|
|
</span> /{" "}
|
|
<span className="text-red-500">
|
|
{entry.failed_tracks || 0}
|
|
</span>
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
}),
|
|
]
|
|
: []),
|
|
],
|
|
[viewChildren, viewEntryDetails, viewingChildren],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const fetchHistory = async () => {
|
|
if (viewingChildren) return; // Skip if viewing children
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({
|
|
limit: `${pageSize}`,
|
|
offset: `${pageIndex * pageSize}`,
|
|
});
|
|
|
|
if (statusFilter) params.append("status", statusFilter);
|
|
if (typeFilter) params.append("download_type", typeFilter);
|
|
|
|
const response = await apiClient.get<{
|
|
downloads: HistoryEntry[];
|
|
pagination: {
|
|
limit: number;
|
|
offset: number;
|
|
returned_count: number;
|
|
};
|
|
}>(`/history?${params.toString()}`);
|
|
|
|
setData(response.data.downloads);
|
|
// Since we don't get total count, estimate based on returned count
|
|
const estimatedTotal = response.data.pagination.returned_count < pageSize
|
|
? pageIndex * pageSize + response.data.pagination.returned_count
|
|
: (pageIndex + 1) * pageSize + 1;
|
|
setTotalEntries(estimatedTotal);
|
|
} catch (error) {
|
|
toast.error("Failed to load history.");
|
|
console.error("Error loading history:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchHistory();
|
|
}, [pageIndex, pageSize, statusFilter, typeFilter, viewingChildren]);
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns,
|
|
pageCount: Math.ceil(totalEntries / pageSize),
|
|
state: { sorting, pagination },
|
|
onPaginationChange: setPagination,
|
|
onSortingChange: setSorting,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
manualPagination: true,
|
|
manualSorting: true,
|
|
});
|
|
|
|
const clearFilters = () => {
|
|
setStatusFilter("");
|
|
setTypeFilter("");
|
|
};
|
|
|
|
const goBackToHistory = () => {
|
|
setViewingChildren(null);
|
|
setPagination({ pageIndex: 0, pageSize });
|
|
clearFilters();
|
|
};
|
|
|
|
const closeDetails = () => {
|
|
setSelectedEntry(null);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Entry Details Modal */}
|
|
{selectedEntry && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-surface dark:bg-surface-dark rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-2xl font-bold text-content-primary dark:text-content-primary-dark">
|
|
Download Details
|
|
</h2>
|
|
<button
|
|
onClick={closeDetails}
|
|
className="text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<h3 className="font-semibold mb-2">Basic Info</h3>
|
|
<div className="space-y-1 text-sm">
|
|
<p><strong>Task ID:</strong> {selectedEntry.task_id}</p>
|
|
<p><strong>Type:</strong> {selectedEntry.download_type}</p>
|
|
<p><strong>Title:</strong> {selectedEntry.title}</p>
|
|
<p><strong>Artists:</strong> {selectedEntry.artists.join(", ")}</p>
|
|
<p><strong>Status:</strong> {selectedEntry.status}</p>
|
|
<p><strong>Service:</strong> {selectedEntry.service}</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold mb-2">Quality & Stats</h3>
|
|
<div className="space-y-1 text-sm">
|
|
<p><strong>Quality:</strong> {formatQuality(selectedEntry)}</p>
|
|
<p><strong>Date:</strong> {new Date(selectedEntry.timestamp * 1000).toLocaleString()}</p>
|
|
{selectedEntry.total_tracks && (
|
|
<>
|
|
<p><strong>Total Tracks:</strong> {selectedEntry.total_tracks}</p>
|
|
<p><strong>Successful:</strong> {selectedEntry.successful_tracks || 0}</p>
|
|
<p><strong>Failed:</strong> {selectedEntry.failed_tracks || 0}</p>
|
|
<p><strong>Skipped:</strong> {selectedEntry.skipped_tracks || 0}</p>
|
|
</>
|
|
)}
|
|
{selectedEntry.duration_total_ms && (
|
|
<p><strong>Duration:</strong> {formatDuration(selectedEntry.duration_total_ms)}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedEntry.external_ids && Object.keys(selectedEntry.external_ids).length > 0 && (
|
|
<div>
|
|
<h3 className="font-semibold mb-2">External IDs</h3>
|
|
<div className="bg-surface-secondary dark:bg-surface-secondary-dark p-3 rounded text-sm">
|
|
<pre className="whitespace-pre-wrap">{JSON.stringify(selectedEntry.external_ids, null, 2)}</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedEntry.metadata && Object.keys(selectedEntry.metadata).length > 0 && (
|
|
<div>
|
|
<h3 className="font-semibold mb-2">Metadata</h3>
|
|
<div className="bg-surface-secondary dark:bg-surface-secondary-dark p-3 rounded text-sm max-h-60 overflow-y-auto">
|
|
<pre className="whitespace-pre-wrap">{JSON.stringify(selectedEntry.metadata, null, 2)}</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedEntry.genres && selectedEntry.genres.length > 0 && (
|
|
<div>
|
|
<h3 className="font-semibold mb-2">Genres</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{selectedEntry.genres.map((genre, index) => (
|
|
<span key={index} className="bg-primary/10 text-primary px-2 py-1 rounded text-sm">
|
|
{genre}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{viewingChildren ? (
|
|
<div className="space-y-4">
|
|
<button onClick={goBackToHistory} className="flex items-center gap-2 text-sm hover:underline text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark">
|
|
← Back to All History
|
|
</button>
|
|
<div className="rounded-lg border border-border dark:border-border-dark bg-gradient-to-br from-surface to-surface-muted dark:from-surface-dark dark:to-surface-muted-dark p-6 shadow-lg">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-1.5">
|
|
<h2 className="text-3xl font-bold tracking-tight text-content-primary dark:text-content-primary-dark">{viewingChildren.title}</h2>
|
|
<div className="pt-2">
|
|
<span className="capitalize inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-surface-accent dark:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark">
|
|
{viewingChildren.download_type}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2 text-sm md:text-right">
|
|
<p className="text-content-muted dark:text-content-muted-dark">
|
|
<span className="font-semibold text-content-primary dark:text-content-primary-dark">Total Tracks: </span>
|
|
{viewingChildren.track_count}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<h3 className="text-2xl font-bold tracking-tight text-content-primary dark:text-content-primary-dark">
|
|
Tracks
|
|
</h3>
|
|
</div>
|
|
) : (
|
|
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Download History</h1>
|
|
)}
|
|
|
|
{/* Filter Controls */}
|
|
{!viewingChildren && (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
className="p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="failed">Failed</option>
|
|
<option value="skipped">Skipped</option>
|
|
<option value="in_progress">In Progress</option>
|
|
<option value="partial">Partial</option>
|
|
</select>
|
|
<select
|
|
value={typeFilter}
|
|
onChange={(e) => setTypeFilter(e.target.value)}
|
|
className="p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
|
>
|
|
<option value="">All Types</option>
|
|
<option value="track">Track</option>
|
|
<option value="album">Album</option>
|
|
<option value="playlist">Playlist</option>
|
|
</select>
|
|
<button
|
|
onClick={clearFilters}
|
|
className="px-4 py-2 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover border border-border dark:border-border-dark rounded-md"
|
|
>
|
|
Clear Filters
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop Table */}
|
|
<div className="hidden lg:block overflow-x-auto">
|
|
<table className="min-w-full">
|
|
<thead>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<tr key={headerGroup.id}>
|
|
{headerGroup.headers.map((header) => (
|
|
<th key={header.id} className="p-2 text-left text-content-primary dark:text-content-primary-dark">
|
|
{header.isPlaceholder ? null : (
|
|
<div
|
|
{...{
|
|
className: header.column.getCanSort() ? "cursor-pointer select-none" : "",
|
|
onClick: header.column.getToggleSortingHandler(),
|
|
}}
|
|
>
|
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
{{ asc: " ▲", desc: " ▼" }[header.column.getIsSorted() as string] ?? null}
|
|
</div>
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<tbody>
|
|
{isLoading ? (
|
|
<tr>
|
|
<td colSpan={columns.length} className="text-center p-4 text-content-muted dark:text-content-muted-dark">
|
|
Loading...
|
|
</td>
|
|
</tr>
|
|
) : table.getRowModel().rows.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={columns.length} className="text-center p-4 text-content-muted dark:text-content-muted-dark">
|
|
No history entries found.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
table.getRowModel().rows.map((row) => {
|
|
const entry = row.original;
|
|
const isChild = "album_title" in entry;
|
|
const isParent = !isChild && "children_table" in entry && entry.children_table;
|
|
let rowClass = "hover:bg-surface-muted dark:hover:bg-surface-muted-dark";
|
|
|
|
if (isParent) {
|
|
rowClass += " bg-surface-accent dark:bg-surface-accent-dark font-semibold";
|
|
} else if (isChild) {
|
|
rowClass += " border-t border-dashed border-content-muted dark:border-content-muted-dark border-opacity-20";
|
|
}
|
|
|
|
return (
|
|
<tr key={row.id} className={`border-b border-border dark:border-border-dark ${rowClass}`}>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<td key={cell.id} className="p-3 text-content-primary dark:text-content-primary-dark">
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Mobile Card Layout */}
|
|
<div className="lg:hidden space-y-3">
|
|
{isLoading ? (
|
|
<div className="text-center p-8 text-content-muted dark:text-content-muted-dark">
|
|
Loading...
|
|
</div>
|
|
) : table.getRowModel().rows.length === 0 ? (
|
|
<div className="text-center p-8 text-content-muted dark:text-content-muted-dark">
|
|
No history entries found.
|
|
</div>
|
|
) : (
|
|
table.getRowModel().rows.map((row) => {
|
|
const entry = row.original;
|
|
const isChild = "album_title" in entry;
|
|
const isParent = !isChild && "children_table" in entry && entry.children_table;
|
|
const status = entry.status;
|
|
const statusClass = STATUS_CLASS[status] || "text-gray-500";
|
|
|
|
let cardClass = "bg-surface dark:bg-surface-secondary-dark rounded-lg border border-border dark:border-border-dark p-4";
|
|
if (isParent) {
|
|
cardClass += " border-l-4 border-l-primary";
|
|
} else if (isChild) {
|
|
cardClass += " ml-4 border-l-2 border-l-content-muted dark:border-l-content-muted-dark";
|
|
}
|
|
|
|
return (
|
|
<div key={row.id} className={cardClass}>
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className={`font-semibold text-content-primary dark:text-content-primary-dark truncate ${isChild ? 'text-sm' : 'text-base'}`}>
|
|
{isChild ? `└─ ${entry.title}` : entry.title}
|
|
</h3>
|
|
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate">
|
|
{Array.isArray(entry.artists) ? entry.artists.join(", ") : entry.artists}
|
|
</p>
|
|
</div>
|
|
<span className={`text-sm font-semibold ${statusClass} ml-2 capitalize`}>
|
|
{status}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Details Grid */}
|
|
<div className="grid grid-cols-2 gap-y-2 gap-x-4 text-sm">
|
|
<div>
|
|
<span className="text-content-muted dark:text-content-muted-dark">Type:</span>
|
|
<span className="ml-1 capitalize text-content-primary dark:text-content-primary-dark">
|
|
{"download_type" in entry ? entry.download_type : "track"}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-content-muted dark:text-content-muted-dark">Service:</span>
|
|
<span className="ml-1 text-content-primary dark:text-content-primary-dark capitalize">
|
|
{"service" in entry ? entry.service : "Unknown"}
|
|
</span>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<span className="text-content-muted dark:text-content-muted-dark">Quality:</span>
|
|
<span className="ml-1 text-content-primary dark:text-content-primary-dark">
|
|
{"download_type" in entry ? formatQuality(entry) : "N/A"}
|
|
</span>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<span className="text-content-muted dark:text-content-muted-dark">Date:</span>
|
|
<span className="ml-1 text-content-primary dark:text-content-primary-dark">
|
|
{new Date(entry.timestamp * 1000).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
{!viewingChildren && !isChild && (
|
|
<div className="mt-3 pt-3 border-t border-border dark:border-border-dark flex items-center justify-between">
|
|
{isParent && (
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="text-success">
|
|
{(entry as HistoryEntry).successful_tracks || 0} ✓
|
|
</span>
|
|
<span className="text-warning">
|
|
{(entry as HistoryEntry).skipped_tracks || 0} ⊘
|
|
</span>
|
|
<span className="text-error">
|
|
{(entry as HistoryEntry).failed_tracks || 0} ✗
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 ml-auto">
|
|
<button
|
|
onClick={() => viewEntryDetails((entry as HistoryEntry).task_id)}
|
|
className="px-3 py-1 text-xs rounded-md bg-gray-600 hover:bg-gray-700 text-white"
|
|
>
|
|
Details
|
|
</button>
|
|
{isParent && (
|
|
<button
|
|
onClick={() => viewChildren(entry as HistoryEntry)}
|
|
className="px-3 py-1 text-xs rounded-md bg-primary hover:bg-primary-hover text-white"
|
|
>
|
|
View Tracks
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination Controls */}
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div className="flex items-center justify-center sm:justify-start gap-2">
|
|
<button
|
|
onClick={() => table.previousPage()}
|
|
disabled={!table.getCanPreviousPage()}
|
|
className="px-4 py-2 border bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover border-border dark:border-border-dark rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
onClick={() => table.nextPage()}
|
|
disabled={!table.getCanNextPage()}
|
|
className="px-4 py-2 border bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover border-border dark:border-border-dark rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row items-center gap-2 text-sm">
|
|
<span className="text-content-primary dark:text-content-primary-dark whitespace-nowrap">
|
|
Page{" "}
|
|
<strong>
|
|
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
|
</strong>
|
|
</span>
|
|
<select
|
|
value={table.getState().pagination.pageSize}
|
|
onChange={(e) => table.setPageSize(Number(e.target.value))}
|
|
className="px-3 py-1 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
|
>
|
|
{[10, 25, 50, 100].map((size) => (
|
|
<option key={size} value={size}>
|
|
Show {size}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|