feat(api): implement rate limiting and SSRF protection across endpoints
- Added rate limiting to `reaction-users`, `search`, and `image-proxy` APIs to prevent abuse. - Introduced SSRF protection in `image-proxy` to block requests to private IP ranges. - Enhanced `link-preview` to use `linkedom` for HTML parsing and improved meta tag extraction. - Refactored authentication checks in various pages to utilize middleware for cleaner code. - Improved JWT key loading with error handling and security warnings for production. - Updated `authFetch` utility to handle token refresh more efficiently with deduplication. - Enhanced rate limiting utility to trust proxy headers from known sources. - Numerous layout / design changes
This commit is contained in:
@@ -7,26 +7,27 @@ export default function BreadcrumbNav({ currentPage }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav aria-label="breadcrumb" className="mb-6 flex gap-4 text-sm font-medium text-blue-600 dark:text-blue-400">
|
||||
{pages.map(({ key, label, href }, i) => {
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<a
|
||||
href={href}
|
||||
className={`${currentPage === key
|
||||
? "!font-bold underline" // active: always underlined + bold
|
||||
: "hover:underline" // inactive: underline only on hover
|
||||
}`}
|
||||
aria-current={currentPage === key ? "page" : undefined}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
{i < pages.length - 1 && <span aria-hidden="true">/</span>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav >
|
||||
</div>
|
||||
<nav aria-label="breadcrumb" className="mb-8 flex items-center gap-2 text-sm">
|
||||
{pages.map(({ key, label, href }, i) => {
|
||||
const isActive = currentPage === key;
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<a
|
||||
href={href}
|
||||
className={`px-3 py-1.5 rounded-full transition-colors ${isActive
|
||||
? "bg-neutral-200 dark:bg-neutral-700 font-semibold text-neutral-900 dark:text-white"
|
||||
: "text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
{i < pages.length - 1 && (
|
||||
<span className="text-neutral-400 dark:text-neutral-600" aria-hidden="true">/</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AutoComplete } from "primereact/autocomplete";
|
||||
import { authFetch } from "@/utils/authFetch";
|
||||
import BreadcrumbNav from "./BreadcrumbNav";
|
||||
import { API_URL, ENVIRONMENT } from "@/config";
|
||||
import "./RequestManagement.css";
|
||||
|
||||
export default function MediaRequestForm() {
|
||||
const [type, setType] = useState("artist");
|
||||
@@ -918,7 +919,7 @@ export default function MediaRequestForm() {
|
||||
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto my-10 p-6 rounded-xl shadow-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700">
|
||||
<div className="trip-request-form mx-auto my-10 p-6 rounded-xl shadow-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700">
|
||||
<style>{`
|
||||
/* Accordion tab backgrounds & text */
|
||||
.p-accordion-tab {
|
||||
@@ -990,7 +991,8 @@ export default function MediaRequestForm() {
|
||||
}
|
||||
`}</style>
|
||||
<BreadcrumbNav currentPage="request" />
|
||||
<h2 className="text-3xl font-semibold mt-0">New Request</h2>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">New Request</h2>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">Search for an artist to browse and select tracks for download.</p>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<label htmlFor="artistInput">Artist: </label>
|
||||
|
||||
@@ -1,81 +1,442 @@
|
||||
/* Table and Dark Overrides */
|
||||
.p-datatable {
|
||||
table-layout: fixed !important;
|
||||
.trip-management-container {
|
||||
width: 100%;
|
||||
}
|
||||
.p-datatable td span.truncate {
|
||||
|
||||
.trip-management-container .table-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable {
|
||||
width: 100% !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable-wrapper {
|
||||
width: 100% !important;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable-table {
|
||||
width: 100% !important;
|
||||
table-layout: fixed !important;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Force header and body rows to fill width */
|
||||
.trip-management-container .p-datatable-thead,
|
||||
.trip-management-container .p-datatable-tbody {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable-thead > tr,
|
||||
.trip-management-container .p-datatable-tbody > tr {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Column widths - distribute across table */
|
||||
.trip-management-container .p-datatable-thead > tr > th,
|
||||
.trip-management-container .p-datatable-tbody > tr > td {
|
||||
/* Default: auto distribute */
|
||||
}
|
||||
|
||||
/* ID column - narrow */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(1),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(1) {
|
||||
width: 10% !important;
|
||||
}
|
||||
|
||||
/* Target column - widest */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(2),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(2) {
|
||||
width: 22% !important;
|
||||
}
|
||||
|
||||
/* Tracks column */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(3),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(3) {
|
||||
width: 10% !important;
|
||||
}
|
||||
|
||||
/* Status column */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(4),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(4) {
|
||||
width: 12% !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Progress column */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(5),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(5) {
|
||||
width: 16% !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Quality column */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(6),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(6) {
|
||||
width: 10% !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Tarball column - fills remaining */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(7),
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(7) {
|
||||
width: 20% !important;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable td span.truncate {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Row hover cursor - indicate clickable */
|
||||
.trip-management-container .p-datatable-tbody > tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Center-align headers for centered columns */
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(4),
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(5),
|
||||
.trip-management-container .p-datatable-thead > tr > th:nth-child(6) {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* Skeleton loading styles */
|
||||
.table-skeleton {
|
||||
width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
display: flex;
|
||||
padding: 1rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
.skeleton-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.skeleton-cell {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-bar {
|
||||
height: 1rem;
|
||||
background: linear-gradient(90deg, #2a2a2a 25%, #3a3a3a 50%, #2a2a2a 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 0.25rem;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Light mode skeleton */
|
||||
[data-theme="light"] .skeleton-bar {
|
||||
background: linear-gradient(90deg, #e5e5e5 25%, #f0f0f0 50%, #e5e5e5 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
/* Empty state styles */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-state-subtext {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Dark Mode for Table */
|
||||
[data-theme="dark"] .p-datatable {
|
||||
background-color: #121212 !important;
|
||||
[data-theme="dark"] .trip-management-container .p-datatable {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-thead > tr > th {
|
||||
[data-theme="dark"] .trip-management-container .p-datatable-thead > tr > th {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #e5e7eb !important;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr {
|
||||
[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr {
|
||||
background-color: #1a1a1a !important;
|
||||
border-bottom: 1px solid #374151;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) {
|
||||
[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr:nth-child(odd) {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr:hover {
|
||||
[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr:hover {
|
||||
background-color: #333 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Paginator Dark Mode */
|
||||
[data-theme="dark"] .p-paginator {
|
||||
[data-theme="dark"] .trip-management-container .p-paginator {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-paginator-page,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-next,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-prev,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-first,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-last {
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-page,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-next,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-prev,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-first,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-last {
|
||||
color: #e5e7eb !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-paginator-page:hover,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-next:hover,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-prev:hover {
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-page:hover,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-next:hover,
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-prev:hover {
|
||||
background-color: #374151 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-highlight {
|
||||
[data-theme="dark"] .trip-management-container .p-paginator .p-highlight {
|
||||
background-color: #6b7280 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Dark Mode for PrimeReact Dialog */
|
||||
[data-theme="dark"] .p-dialog {
|
||||
/* Dark Mode for PrimeReact Dialog - rendered via portal so needs global selector */
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 {
|
||||
background-color: #1a1a1a !important;
|
||||
color: #e5e7eb !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-dialog-header {
|
||||
background-color: #121212 !important;
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header {
|
||||
background-color: #171717 !important;
|
||||
color: #e5e7eb !important;
|
||||
border-bottom: 1px solid #374151 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-dialog-content {
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header .p-dialog-header-icon {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header .p-dialog-header-icon:hover {
|
||||
background-color: #374151 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-content {
|
||||
background-color: #1a1a1a !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-dialog-footer {
|
||||
background-color: #121212 !important;
|
||||
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-footer {
|
||||
background-color: #171717 !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
}
|
||||
|
||||
/* Progress Bar Styles */
|
||||
.progress-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-track-lg {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-bar-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Container Styles */
|
||||
.trip-management-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trip-management-container .overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.trip-request-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.trip-management-container {
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.trip-management-container h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Stack filters on mobile */
|
||||
.trip-management-container .flex-wrap {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Make table horizontally scrollable */
|
||||
.trip-management-container .overflow-x-auto {
|
||||
margin: 0 -1rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Reduce column widths on mobile */
|
||||
.p-datatable-thead > tr > th,
|
||||
.p-datatable-tbody > tr > td {
|
||||
padding: 0.5rem 0.25rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* Hide less important columns on small screens */
|
||||
.p-datatable .hide-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.trip-management-container {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar-text {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== MediaRequestForm Mobile Styles ===== */
|
||||
|
||||
/* Form container responsive */
|
||||
.trip-request-form {
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
margin: 2.5rem auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.trip-request-form {
|
||||
margin: 1rem auto;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.trip-request-form h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Quality buttons stack on mobile */
|
||||
.trip-quality-buttons {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.trip-quality-buttons button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Accordion improvements for mobile */
|
||||
.p-accordion-header .p-accordion-header-link {
|
||||
padding: 0.75rem !important;
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
.p-accordion-content {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Track list items more compact on mobile */
|
||||
.p-accordion-content li {
|
||||
padding: 0.5rem !important;
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
/* Album header info stacks */
|
||||
.album-header-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Audio player controls smaller on mobile */
|
||||
.track-audio-controls button {
|
||||
padding: 0.25rem 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.trip-request-form {
|
||||
margin: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.trip-request-form h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Input fields full width */
|
||||
.p-autocomplete,
|
||||
.p-autocomplete-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Smaller text in track listings */
|
||||
.p-accordion-content li span {
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Submit button full width */
|
||||
.trip-submit-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { authFetch } from "@/utils/authFetch";
|
||||
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
|
||||
import BreadcrumbNav from "./BreadcrumbNav";
|
||||
import { API_URL } from "@/config";
|
||||
import "./RequestManagement.css";
|
||||
|
||||
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
|
||||
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
|
||||
@@ -20,6 +21,7 @@ export default function RequestManagement() {
|
||||
const [filteredRequests, setFilteredRequests] = useState([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
||||
const [isDialogVisible, setIsDialogVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const pollingRef = useRef(null);
|
||||
const pollingDetailRef = useRef(null);
|
||||
|
||||
@@ -30,8 +32,9 @@ export default function RequestManagement() {
|
||||
return `${TAR_BASE_URL}/${quality}/${filename}`;
|
||||
};
|
||||
|
||||
const fetchJobs = async () => {
|
||||
const fetchJobs = async (showLoading = true) => {
|
||||
try {
|
||||
if (showLoading) setIsLoading(true);
|
||||
const res = await authFetch(`${API_URL}/trip/jobs/list`);
|
||||
if (!res.ok) throw new Error("Failed to fetch jobs");
|
||||
const data = await res.json();
|
||||
@@ -43,6 +46,8 @@ export default function RequestManagement() {
|
||||
toastId: 'fetch-fail-toast',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,13 +128,13 @@ export default function RequestManagement() {
|
||||
|
||||
|
||||
const statusBodyTemplate = (rowData) => (
|
||||
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getStatusColorClass(rowData.status)}`}>
|
||||
<span className={`inline-flex items-center justify-center min-w-[90px] px-3 py-1 rounded-full font-semibold text-xs ${getStatusColorClass(rowData.status)}`}>
|
||||
{rowData.status}
|
||||
</span>
|
||||
);
|
||||
|
||||
const qualityBodyTemplate = (rowData) => (
|
||||
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getQualityColorClass(rowData.quality)}`}>
|
||||
<span className={`inline-flex items-center justify-center min-w-[50px] px-3 py-1 rounded-full font-semibold text-xs ${getQualityColorClass(rowData.quality)}`}>
|
||||
{rowData.quality}
|
||||
</span>
|
||||
);
|
||||
@@ -158,6 +163,34 @@ export default function RequestManagement() {
|
||||
return `${pct}%`;
|
||||
};
|
||||
|
||||
const progressBarTemplate = (rowData) => {
|
||||
const p = rowData.progress;
|
||||
if (p === null || p === undefined || p === "") return "—";
|
||||
const num = Number(p);
|
||||
if (Number.isNaN(num)) return "—";
|
||||
const pct = Math.min(100, Math.max(0, num > 1 ? Math.round(num) : num * 100));
|
||||
|
||||
const getProgressColor = () => {
|
||||
if (rowData.status === "Failed") return "bg-red-500";
|
||||
if (rowData.status === "Finished") return "bg-green-500";
|
||||
if (pct < 30) return "bg-blue-400";
|
||||
if (pct < 70) return "bg-blue-500";
|
||||
return "bg-blue-600";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar-track">
|
||||
<div
|
||||
className={`progress-bar-fill ${getProgressColor()}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="progress-bar-text">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const confirmDelete = (requestId) => {
|
||||
confirmDialog({
|
||||
message: "Are you sure you want to delete this request?",
|
||||
@@ -195,100 +228,15 @@ export default function RequestManagement() {
|
||||
|
||||
return (
|
||||
|
||||
<div className="w-max my-10 p-6 rounded-xl shadow-md
|
||||
<div className="trip-management-container my-10 p-4 sm:p-6 rounded-xl shadow-md
|
||||
bg-white dark:bg-neutral-900
|
||||
text-neutral-900 dark:text-neutral-100
|
||||
border border-neutral-200 dark:border-neutral-700
|
||||
sm:p-4 md:p-6">
|
||||
<style>{`
|
||||
/* Table and Dark Overrides */
|
||||
.p-datatable {
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
.p-datatable td span.truncate {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-thead > tr > th {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #e5e7eb !important;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr {
|
||||
background-color: #1a1a1a !important;
|
||||
border-bottom: 1px solid #374151;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr:hover {
|
||||
background-color: #333 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
/* Paginator Dark Mode */
|
||||
[data-theme="dark"] .p-paginator {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-paginator-page,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-next,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-prev,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-first,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-last {
|
||||
color: #e5e7eb !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-paginator-page:hover,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-next:hover,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-prev:hover {
|
||||
background-color: #374151 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-highlight {
|
||||
background-color: #6b7280 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Dark mode for PrimeReact Dialog */
|
||||
[data-theme="dark"] .p-dialog {
|
||||
background-color: #1a1a1a !important;
|
||||
color: #e5e7eb !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-dialog .p-dialog-header {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
border-bottom: 1px solid #374151 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-dialog .p-dialog-content {
|
||||
background-color: #1a1a1a !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-dialog .p-dialog-footer {
|
||||
background-color: #121212 !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
`}</style>
|
||||
border border-neutral-200 dark:border-neutral-700">
|
||||
|
||||
<BreadcrumbNav currentPage="management" />
|
||||
<h2 className="text-3xl font-semibold mt-0">Media Request Management</h2>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-6">Manage Requests</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-6 mb-6">
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||
<Dropdown
|
||||
value={filterStatus}
|
||||
options={[{ label: "All Statuses", value: "all" }, ...STATUS_OPTIONS.map((s) => ({ label: s, value: s }))]}
|
||||
@@ -298,68 +246,91 @@ export default function RequestManagement() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-max overflow-x-auto rounded-lg">
|
||||
<DataTable
|
||||
value={filteredRequests}
|
||||
paginator
|
||||
rows={10}
|
||||
removableSort
|
||||
sortMode="multiple"
|
||||
emptyMessage="No requests found."
|
||||
onRowClick={handleRowClick}
|
||||
>
|
||||
|
||||
<Column
|
||||
field="id"
|
||||
header="ID"
|
||||
style={{ width: "6rem" }}
|
||||
body={(row) => (
|
||||
<span title={row.id}>
|
||||
{row.id.split("-").slice(-1)[0]}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<Column field="target" header="Target" sortable style={{ width: "12rem" }} body={(row) => textWithEllipsis(row.target, "10rem")} />
|
||||
<Column field="tracks" header="# Tracks" style={{ width: "8rem" }} body={(row) => row.tracks} />
|
||||
<Column field="status" header="Status" body={statusBodyTemplate} style={{ width: "10rem", textAlign: "center" }} sortable />
|
||||
<Column field="progress" header="Progress" body={(row) => formatProgress(row.progress)} style={{ width: "8rem", textAlign: "center" }} sortable />
|
||||
<Column
|
||||
field="quality"
|
||||
header="Quality"
|
||||
body={qualityBodyTemplate}
|
||||
style={{ width: "6rem", textAlign: "center" }}
|
||||
sortable />
|
||||
<Column
|
||||
field="tarball"
|
||||
header={
|
||||
<span className="flex items-center">
|
||||
<i className="pi pi-download mr-1" /> {/* download icon in header */}
|
||||
Tarball
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<div className="table-skeleton">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="skeleton-row">
|
||||
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[22%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[12%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[16%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
|
||||
<div className="skeleton-cell w-[20%]"><div className="skeleton-bar" /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper w-full">
|
||||
<DataTable
|
||||
value={filteredRequests}
|
||||
paginator
|
||||
rows={10}
|
||||
removableSort
|
||||
sortMode="multiple"
|
||||
emptyMessage={
|
||||
<div className="empty-state">
|
||||
<i className="pi pi-inbox empty-state-icon" />
|
||||
<p className="empty-state-text">No requests found</p>
|
||||
<p className="empty-state-subtext">Requests you submit will appear here</p>
|
||||
</div>
|
||||
}
|
||||
body={(row) => {
|
||||
const url = tarballUrl(row.tarball, row.quality || "FLAC");
|
||||
const encodedURL = encodeURI(url);
|
||||
if (!url) return "—";
|
||||
onRowClick={handleRowClick}
|
||||
resizableColumns={false}
|
||||
className="w-full"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
|
||||
const fileName = url.split("/").pop();
|
||||
<Column
|
||||
field="id"
|
||||
header="ID"
|
||||
body={(row) => (
|
||||
<span title={row.id}>
|
||||
{row.id.split("-").slice(-1)[0]}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<Column field="target" header="Target" sortable body={(row) => textWithEllipsis(row.target, "100%")} />
|
||||
<Column field="tracks" header="# Tracks" body={(row) => row.tracks} />
|
||||
<Column field="status" header="Status" body={statusBodyTemplate} style={{ textAlign: "center" }} sortable />
|
||||
<Column field="progress" header="Progress" body={progressBarTemplate} style={{ textAlign: "center" }} sortable />
|
||||
<Column
|
||||
field="quality"
|
||||
header="Quality"
|
||||
body={qualityBodyTemplate}
|
||||
style={{ textAlign: "center" }}
|
||||
sortable />
|
||||
<Column
|
||||
field="tarball"
|
||||
header={
|
||||
<span className="flex items-center">
|
||||
<i className="pi pi-download mr-1" />
|
||||
Tarball
|
||||
</span>
|
||||
}
|
||||
body={(row) => {
|
||||
const url = tarballUrl(row.tarball, row.quality || "FLAC");
|
||||
const encodedURL = encodeURI(url);
|
||||
if (!url) return "—";
|
||||
|
||||
return (
|
||||
<a
|
||||
href={encodedURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-blue-500 hover:underline"
|
||||
title={fileName}
|
||||
>
|
||||
{truncate(fileName, 16)}
|
||||
</a>
|
||||
);
|
||||
}}
|
||||
style={{ width: "10rem" }}
|
||||
/>
|
||||
</DataTable>
|
||||
</div>
|
||||
const fileName = url.split("/").pop();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={encodedURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-blue-500 hover:underline"
|
||||
title={fileName}
|
||||
>
|
||||
{truncate(fileName, 28)}
|
||||
</a>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</DataTable>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog />
|
||||
|
||||
@@ -402,7 +373,18 @@ export default function RequestManagement() {
|
||||
</p>
|
||||
)}
|
||||
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
|
||||
<p><strong>Progress:</strong> {formatProgress(selectedRequest.progress)}</p>
|
||||
<div className="col-span-2">
|
||||
<strong>Progress:</strong>
|
||||
<div className="progress-bar-container mt-2">
|
||||
<div className="progress-bar-track progress-bar-track-lg">
|
||||
<div
|
||||
className={`progress-bar-fill ${selectedRequest.status === "Failed" ? "bg-red-500" : selectedRequest.status === "Finished" ? "bg-green-500" : "bg-blue-500"}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, Number(selectedRequest.progress) > 1 ? Math.round(selectedRequest.progress) : selectedRequest.progress * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="progress-bar-text">{formatProgress(selectedRequest.progress)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user