This commit is contained in:
2025-08-20 07:32:40 -04:00
parent e51be9697c
commit fdbc84aee5
6 changed files with 276 additions and 391 deletions

View File

@@ -30,8 +30,6 @@ html {
@apply absolute invisible no-underline; @apply absolute invisible no-underline;
margin-left: -1em; margin-left: -1em;
padding-right: 0.5em; padding-right: 0.5em;
width: 80%;
max-width: 700px;
cursor: pointer; cursor: pointer;
} }

View File

@@ -2,8 +2,8 @@ import React from "react";
export default function BreadcrumbNav({ currentPage }) { export default function BreadcrumbNav({ currentPage }) {
const pages = [ const pages = [
{ key: "request", label: "Request Media", href: "/qs2" }, { key: "request", label: "Request Media", href: "/TRip" },
{ key: "management", label: "Manage Requests", href: "/qs2/requests" }, { key: "management", label: "Manage Requests", href: "/TRip/requests" },
]; ];
return ( return (

View File

@@ -400,7 +400,10 @@ export default function MediaRequestForm() {
headers: { headers: {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
}, },
body: JSON.stringify({ track_ids: allSelectedIds }), body: JSON.stringify({
track_ids: allSelectedIds,
target: selectedArtist.artist
}),
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, Suspense, lazy } from "react"; import React, { useState, useEffect, useRef } from "react";
import { toast } from 'react-toastify'; import { toast } from "react-toastify";
import { DataTable } from "primereact/datatable"; import { DataTable } from "primereact/datatable";
import { Column } from "primereact/column"; import { Column } from "primereact/column";
import { Dropdown } from "primereact/dropdown"; import { Dropdown } from "primereact/dropdown";
@@ -7,87 +7,120 @@ import { Button } from "@mui/joy";
import { Dialog } from "primereact/dialog"; import { Dialog } from "primereact/dialog";
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog"; import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
import BreadcrumbNav from "./BreadcrumbNav"; import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL } from "@/config";
const STATUS_OPTIONS = ["queued", "started", "compressing", "finished", "failed"];
const STATUS_OPTIONS = ["Pending", "Completed", "Failed"]; const TAR_BASE_URL = "https://codey.lol/m/m2/"; // configurable prefix
const TYPE_OPTIONS = ["Artist", "Album", "Track"];
const initialRequests = [
{
id: 1,
type: "Artist",
artist: "Bring Me The Horizon",
album: "",
track: "",
status: "Pending",
details: {
requestedBy: "codey",
timestamp: "2025-07-01T12:00:00Z",
comments: "",
},
},
{
id: 2,
type: "Track",
artist: "We Butter The Bread With Butter",
album: "Das Album",
track: "20 km/h",
status: "Failed",
details: {
requestedBy: "codey",
timestamp: "2025-06-11T09:00:00Z",
comments: "Track not found in external database.",
},
},
{
id: 3,
type: "Track",
artist: "We Butter The Bread With Butter",
album: "Das Album",
track: "20 km/h",
status: "Completed",
details: {
requestedBy: "codey",
timestamp: "2025-06-11T09:00:00Z",
comments: "Track retrieved successfully.",
},
},
];
export default function RequestManagement() { export default function RequestManagement() {
const [requests, setRequests] = useState(initialRequests); const [requests, setRequests] = useState([]);
const [filterType, setFilterType] = useState(null); const [filterType, setFilterType] = useState(null);
const [filterStatus, setFilterStatus] = useState(null); const [filterStatus, setFilterStatus] = useState(null);
const [filteredRequests, setFilteredRequests] = useState(initialRequests); const [filteredRequests, setFilteredRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null); const [selectedRequest, setSelectedRequest] = useState(null);
const [isDialogVisible, setIsDialogVisible] = useState(false); const [isDialogVisible, setIsDialogVisible] = useState(false);
const pollingRef = useRef(null);
const pollingDetailRef = useRef(null);
const authFetch = async (url, options = {}) => fetch(url, { ...options, credentials: "include" });
useEffect(() => { const tarballUrl = (absPath) => {
let filtered = [...requests]; if (!absPath) return null;
if (filterType) { const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
filtered = filtered.filter((r) => r.type === filterType); return `${TAR_BASE_URL}${filename}`;
} };
if (filterStatus) {
filtered = filtered.filter((r) => r.status === filterStatus);
}
setFilteredRequests(filtered);
}, [filterType, filterStatus, requests]);
const getStatusTextColor = (status) => { const fetchJobs = async () => {
switch (status) { try {
case "Pending": const res = await authFetch(`${API_URL}/trip/jobs/list`);
return "text-yellow-500"; if (!res.ok) throw new Error("Failed to fetch jobs");
case "Completed": const data = await res.json();
return "text-green-500"; setRequests(Array.isArray(data.jobs) ? data.jobs : []);
case "Failed": } catch (err) {
return "text-red-500"; console.error(err);
default: toast.error("Failed to fetch jobs list");
return "text-neutral-500";
} }
}; };
const fetchJobDetail = async (jobId) => {
try {
const res = await authFetch(`${API_URL}/trip/job/${jobId}`);
if (!res.ok) throw new Error("Failed to fetch job details");
return await res.json();
} catch (err) {
console.error(err);
toast.error("Failed to fetch job details");
return null;
}
};
useEffect(() => { fetchJobs(); }, []);
useEffect(() => {
if (isDialogVisible && selectedRequest) {
// Start polling
const poll = async () => {
const updated = await fetchJobDetail(selectedRequest.id);
if (updated) setSelectedRequest(updated);
};
pollingDetailRef.current = setInterval(poll, 1500);
return () => {
if (pollingDetailRef.current) {
clearInterval(pollingDetailRef.current);
pollingDetailRef.current = null;
}
};
}
}, [isDialogVisible, selectedRequest?.id]);
useEffect(() => {
const hasActive = requests.some((j) => ["queued", "started", "compressing"].includes(j.status));
if (hasActive && !pollingRef.current) pollingRef.current = setInterval(fetchJobs, 1500);
else if (!hasActive && pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
return () => { if (pollingRef.current) clearInterval(pollingRef.current); pollingRef.current = null; };
}, [requests]);
useEffect(() => {
let filtered = [...requests];
if (filterType) filtered = filtered.filter((r) => r.type === filterType);
if (filterStatus) filtered = filtered.filter((r) => r.status === filterStatus);
setFilteredRequests(filtered);
}, [filterType, filterStatus, requests]);
const getStatusColorClass = (status) => {
switch (status) {
case "queued": return "bg-yellow-500 text-black";
case "started":
case "compressing": return "bg-blue-500 text-white";
case "finished": return "bg-green-500 text-white";
case "failed": return "bg-red-500 text-white";
default: return "bg-gray-500 text-white";
}
};
const statusBodyTemplate = (rowData) => (
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getStatusColorClass(rowData.status)}`}>
{rowData.status}
</span>
);
const safeText = (val) => (val === 0 ? "0" : val || "—");
const textWithEllipsis = (val, width = "12rem") => (
<span className="truncate block" style={{ maxWidth: width }} title={val || ""}>{val || "—"}</span>
);
const basename = (p) => (typeof p === "string" ? p.split("/").pop() : "");
const formatProgress = (p) => {
if (p === null || p === undefined || p === "") return "—";
const num = Number(p);
if (Number.isNaN(num)) return "—";
const pct = num > 1 ? Math.round(num) : Math.round(num * 100);
return `${pct}%`;
};
const confirmDelete = (requestId) => { const confirmDelete = (requestId) => {
confirmDialog({ confirmDialog({
@@ -103,391 +136,243 @@ export default function RequestManagement() {
toast.success("Request deleted"); toast.success("Request deleted");
}; };
const statusBodyTemplate = (rowData) => { const actionBodyTemplate = (rowData) => (
let colorClass = ""; <Button
color="neutral"
variant="outlined"
size="sm"
sx={{
color: "#e5e7eb",
borderColor: "#6b7280",
'&:hover': { backgroundColor: '#374151', borderColor: '#9ca3af' },
}}
onClick={(e) => { e.stopPropagation(); confirmDelete(rowData.id); }}
>
Delete
</Button>
);
switch (rowData.status) { const handleRowClick = async (e) => {
case "Pending": const detail = await fetchJobDetail(e.data.id);
colorClass = "bg-yellow-300 text-yellow-900"; if (detail) { setSelectedRequest(detail); setIsDialogVisible(true); }
break;
case "Completed":
colorClass = "bg-green-300 text-green-900";
break;
case "Failed":
colorClass = "bg-red-300 text-red-900";
break;
default:
colorClass = "bg-gray-300 text-gray-900";
}
return (
<span
className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${colorClass}`}
aria-label={`Status: ${rowData.status}`}
>
{rowData.status}
</span>
);
};
const actionBodyTemplate = (rowData) => {
return (
<Button
color="danger"
size="sm"
onClick={() => confirmDelete(rowData.id)}
variant="outlined"
>
Delete
</Button>
);
}; };
return ( return (
<div
className="bg-white dark:bg-neutral-900 dark:text-neutral-100 rounded-xl shadow-md border border-neutral-200 dark:border-neutral-700 p-6 space-y-6"
style={{ display: "inline-block" }}
>
<style>{`
.p-datatable {
background-color: white;
color: #1a1a1a;
border-color: #ccc;
}
.p-datatable-header {
background-color: #f9f9f9;
color: #333;
border-bottom: 1px solid #ccc;
}
.p-datatable-thead > tr > th {
background-color: #f0f0f0;
color: #222;
border-bottom: 1px solid #ccc;
}
.p-datatable-tbody > tr > td {
border-bottom: 1px solid #eee;
}
/* Dark mode improvements with brighter text */ <div className="w-max my-10 p-6 rounded-xl shadow-md
[data-theme="dark"] .p-datatable { bg-white dark:bg-neutral-900
background-color: #121212; text-neutral-900 dark:text-neutral-100
color: #f0f0f0; /* brighter text */ border border-neutral-200 dark:border-neutral-700
border-color: #333; sm:p-4 md:p-6">
<style>{`
/* Table and Dark Overrides */
.p-datatable {
table-layout: fixed !important;
} }
[data-theme="dark"] .p-datatable-header { .p-datatable td span.truncate {
background-color: #1f1f1f; display: block;
color: #f5f5f5; /* brighter */ overflow: hidden;
border-bottom: 1px solid #444; 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 { [data-theme="dark"] .p-datatable-thead > tr > th {
background-color: #222222; background-color: #1f1f1f !important;
color: #f5f5f5; /* brighter */ color: #e5e7eb !important;
border-bottom: 1px solid #555; border-bottom: 1px solid #374151;
} }
[data-theme="dark"] .p-datatable-tbody > tr { [data-theme="dark"] .p-datatable-tbody > tr {
background-color: #1a1a1a; background-color: #1a1a1a !important;
border-bottom: 1px solid #333; border-bottom: 1px solid #374151;
transition: background-color 0.2s ease; color: #e5e7eb !important;
color: #f0f0f0; /* brighter text */
} }
/* Zebra stripes */
[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) { [data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) {
background-color: #181818; background-color: #222 !important;
color: #f0f0f0;
} }
/* Hover effect */
[data-theme="dark"] .p-datatable-tbody > tr:hover { [data-theme="dark"] .p-datatable-tbody > tr:hover {
background-color: #333333; background-color: #333 !important;
cursor: pointer;
color: #fff; /* brightest on hover */
}
/* Dropdown inside dark mode */
[data-theme="dark"] .p-dropdown,
[data-theme="dark"] .p-dropdown-label,
[data-theme="dark"] .p-dropdown-panel,
[data-theme="dark"] .p-dropdown-item {
background-color: #2a2a2a !important;
color: #f0f0f0 !important; /* brighter */
}
/* Buttons */
[data-theme="dark"] .p-button {
background-color: transparent !important;
color: #f0f0f0 !important; /* brighter */
border-color: #555 !important;
transition: background-color 0.2s ease;
}
[data-theme="dark"] .p-button:hover {
background-color: #555 !important;
color: #fff !important; color: #fff !important;
} }
[data-theme="dark"] .p-button.p-button-danger { /* Paginator Dark Mode */
border-color: #ff4d4f !important;
color: #ff4d4f !important;
}
[data-theme="dark"] .p-button.p-button-danger:hover {
background-color: #ff4d4f !important;
color: #fff !important;
}
/* ===== PrimeReact Paginator Light Mode ===== */
.p-paginator {
background-color: #fff;
color: #1a1a1a;
border-top: 1px solid #ddd;
}
.p-paginator .p-paginator-page,
.p-paginator .p-paginator-next,
.p-paginator .p-paginator-prev {
color: #1a1a1a;
border: none;
background: transparent;
}
.p-paginator .p-highlight {
background-color: #007bff;
color: white;
border-radius: 6px;
}
/* ===== PrimeReact Paginator Dark Mode ===== */
[data-theme="dark"] .p-paginator { [data-theme="dark"] .p-paginator {
background-color: #121212; background-color: #121212 !important;
color: #f0f0f0; color: #e5e7eb !important;
border-top: 1px solid #333; border-top: 1px solid #374151 !important;
} }
[data-theme="dark"] .p-paginator .p-paginator-page, [data-theme="dark"] .p-paginator .p-paginator-page,
[data-theme="dark"] .p-paginator .p-paginator-next, [data-theme="dark"] .p-paginator .p-paginator-next,
[data-theme="dark"] .p-paginator .p-paginator-prev { [data-theme="dark"] .p-paginator .p-paginator-prev,
color: #ccc; [data-theme="dark"] .p-paginator .p-paginator-first,
border: none; [data-theme="dark"] .p-paginator .p-paginator-last {
background: transparent; 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-page:hover,
[data-theme="dark"] .p-paginator .p-paginator-next:hover, [data-theme="dark"] .p-paginator .p-paginator-next:hover,
[data-theme="dark"] .p-paginator .p-paginator-prev:hover { [data-theme="dark"] .p-paginator .p-paginator-prev:hover {
background-color: #333; background-color: #374151 !important;
color: #fff; color: #fff !important;
border-radius: 6px; border-radius: 0.25rem;
} }
[data-theme="dark"] .p-paginator .p-highlight { [data-theme="dark"] .p-paginator .p-highlight {
background-color: #3b82f6; /* Tailwind blue-500 */ background-color: #6b7280 !important;
color: white; color: #fff !important;
border-radius: 6px; border-radius: 0.25rem !important;
}
.p-paginator .p-paginator-first,
.p-paginator .p-paginator-last {
color: #1a1a1a;
background: transparent;
border: none;
}
.p-paginator .p-paginator-first:hover,
.p-paginator .p-paginator-last:hover {
background-color: #f0f0f0;
border-radius: 6px;
} }
/* Dark Mode: First/Last page buttons */ /* Dark mode for PrimeReact Dialog */
[data-theme="dark"] .p-paginator .p-paginator-first,
[data-theme="dark"] .p-paginator .p-paginator-last {
color: #ccc;
background: transparent;
border: none;
}
[data-theme="dark"] .p-paginator .p-paginator-first:hover,
[data-theme="dark"] .p-paginator .p-paginator-last:hover {
background-color: #333;
color: #fff;
border-radius: 6px;
}
/* ConfirmDialog - Light Theme */
.p-confirm-dialog {
background-color: #ffffff;
color: #1a1a1a;
border: 1px solid #ccc;
}
.p-confirm-dialog .p-dialog-header {
background-color: #f5f5f5;
color: #222;
}
.p-confirm-dialog .p-dialog-content {
background-color: #ffffff;
color: #333;
}
.p-confirm-dialog .p-dialog-footer {
background-color: #fafafa;
border-top: 1px solid #ddd;
}
.p-confirm-dialog .p-button {
border-radius: 0.5rem;
}
/* ConfirmDialog - Dark Theme */
[data-theme='dark'] .p-confirm-dialog {
background-color: #2a2a2a;
color: #e5e5e5;
border: 1px solid #444;
}
[data-theme='dark'] .p-confirm-dialog .p-dialog-header {
background-color: #1f1f1f;
color: #ddd;
}
[data-theme='dark'] .p-confirm-dialog .p-dialog-content {
background-color: #2a2a2a;
color: #ccc;
}
[data-theme='dark'] .p-confirm-dialog .p-dialog-footer {
background-color: #242424;
border-top: 1px solid #333;
}
[data-theme='dark'] .p-confirm-dialog .p-button {
background-color: transparent !important;
color: #ddd !important;
border-color: #555 !important;
}
[data-theme='dark'] .p-confirm-dialog .p-button.p-button-danger {
color: #ff4d4f !important;
border-color: #ff4d4f !important;
}
[data-theme='dark'] .p-dialog-title {
color: #eee;
}
[data-theme='dark'] .p-dialog-header-icon {
color: #ccc;
}
/* PrimeReact Dialog Theming */
.p-dialog {
background-color: #ffffff;
color: #000000;
}
[data-theme="dark"] .p-dialog { [data-theme="dark"] .p-dialog {
background-color: #1e1e1e; background-color: #1a1a1a !important;
color: #ffffff; color: #e5e7eb !important;
border-color: #374151 !important;
} }
[data-theme="dark"] .p-dialog .p-dialog-header { [data-theme="dark"] .p-dialog .p-dialog-header {
background-color: #1e1e1e; background-color: #121212 !important;
border-bottom: 1px solid #444; color: #e5e7eb !important;
border-bottom: 1px solid #374151 !important;
} }
[data-theme="dark"] .p-dialog .p-dialog-content { [data-theme="dark"] .p-dialog .p-dialog-content {
background-color: #2a2a2a; background-color: #1a1a1a !important;
color: #ffffff; color: #e5e7eb !important;
} }
[data-theme="dark"] .p-dialog .p-dialog-footer { [data-theme="dark"] .p-dialog .p-dialog-footer {
background-color: #1e1e1e; background-color: #121212 !important;
border-top: 1px solid #444; border-top: 1px solid #374151 !important;
color: #e5e7eb !important;
} }
`}</style>
`}</style>
<BreadcrumbNav currentPage="management" /> <BreadcrumbNav currentPage="management" />
<h2 className="text-3xl font-semibold" style={{ marginTop: 0 }}> <h2 className="text-3xl font-semibold mt-0">Media Request Management</h2>
Media Request Management
</h2>
<div className="flex flex-wrap gap-6 mb-6"> <div className="flex flex-wrap gap-6 mb-6">
<Dropdown
value={filterType}
options={[
{ label: "All Types", value: null },
...TYPE_OPTIONS.map((t) => ({ label: t, value: t })),
]}
onChange={(e) => setFilterType(e.value)}
placeholder="Filter by Type"
className="min-w-[180px]"
/>
<Dropdown <Dropdown
value={filterStatus} value={filterStatus}
options={[ options={[{ label: "All Statuses", value: null }, ...STATUS_OPTIONS.map((s) => ({ label: s, value: s }))]}
{ label: "All Statuses", value: null },
...STATUS_OPTIONS.map((s) => ({ label: s, value: s })),
]}
onChange={(e) => setFilterStatus(e.value)} onChange={(e) => setFilterStatus(e.value)}
placeholder="Filter by Status" placeholder="Filter by Status"
className="min-w-[180px]" className="min-w-[180px]"
/> />
</div> </div>
<DataTable <div className="w-max overflow-x-auto rounded-lg">
value={filteredRequests} <DataTable
paginator value={filteredRequests}
rows={10} paginator
removableSort rows={10}
sortMode="multiple" removableSort
emptyMessage="No requests found." sortMode="multiple"
rowClassName="!py-4" emptyMessage="No requests found."
className="min-w-full bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100" responsiveLayout="scroll"
onRowClick={(e) => { onRowClick={handleRowClick}
setSelectedRequest(e.data); >
setIsDialogVisible(true); <Column field="target" header="Target" sortable style={{ width: "12rem" }} body={(row) => textWithEllipsis(row.target, "10rem")} />
}} <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="id" header="ID" sortable style={{ width: "5rem" }} /> <Column
<Column field="type" header="Type" sortable /> field="tarball"
<Column field="artist" header="Artist" sortable /> header="Tarball"
<Column field="album" header="Album" sortable /> body={(row) => {
<Column field="track" header="Track" sortable /> const url = tarballUrl(row.tarball);
<Column return url ? (
field="status" <a
header="Status" href={url}
body={statusBodyTemplate} target="_blank"
style={{ width: "10rem", textAlign: "center" }} rel="noopener noreferrer"
sortable className="text-blue-500 hover:underline truncate block"
/> title={url.split("/").pop()}
<Column >
body={actionBodyTemplate} {url.split("/").pop()}
header="Actions" </a>
style={{ width: "9rem", textAlign: "center" }} ) : (
/> "—"
</DataTable> );
}}
style={{ width: "18rem" }}
/>
</DataTable>
</div>
<ConfirmDialog /> <ConfirmDialog />
<Dialog <Dialog
header="Request Details" header="Request Details"
visible={isDialogVisible} visible={isDialogVisible}
style={{ width: "500px" }} style={{ width: "500px" }}
onHide={() => setIsDialogVisible(false)} onHide={() => setIsDialogVisible(false)}
breakpoints={{ '960px': '95vw' }} breakpoints={{ "960px": "95vw" }}
modal modal
dismissableMask dismissableMask
className="dark:bg-neutral-900 dark:text-neutral-100"
> >
{selectedRequest && ( {selectedRequest ? (
<div className="space-y-4 text-sm"> <div className="space-y-4 text-sm">
<p><strong>ID:</strong> {selectedRequest.id}</p> <p><strong>Target:</strong> {selectedRequest.target}</p>
<p><strong>Type:</strong> {selectedRequest.type}</p>
<p><strong>Artist:</strong> {selectedRequest.artist}</p> <p>
{selectedRequest.album && <p><strong>Album:</strong> {selectedRequest.album}</p>} <strong>Status:</strong>{" "}
{selectedRequest.track && <p><strong>Track:</strong> {selectedRequest.track}</p>}
<p className="text-sm flex items-center gap-2">
<span className="font-semibold">Status:</span>
<span <span
className={`px-2 py-0.5 rounded-full text-xs font-bold ${selectedRequest.status === "Pending" className={`px-2 py-0.5 rounded-full text-xs font-bold ${getStatusColorClass(selectedRequest.status)}`}
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100"
: selectedRequest.status === "Completed"
? "bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100"
: "bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100"
}`}
> >
{selectedRequest.status} {selectedRequest.status}
</span> </span>
</p> </p>
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
<p><strong>Progress:</strong> {formatProgress(selectedRequest.progress)}</p>
)}
{selectedRequest.details && ( {selectedRequest.enqueued_at && (
<> <p><strong>Enqueued:</strong> {new Date(selectedRequest.enqueued_at).toLocaleString()}</p>
<p><strong>Requested By:</strong> {selectedRequest.details.requestedBy}</p> )}
<p><strong>Timestamp:</strong> {new Date(selectedRequest.details.timestamp).toLocaleString()}</p> {selectedRequest.started_at && (
{selectedRequest.details.comments && ( <p><strong>Started:</strong> {new Date(selectedRequest.started_at).toLocaleString()}</p>
<p><strong>Comments:</strong> {selectedRequest.details.comments}</p> )}
)} {selectedRequest.ended_at && (
</> <p><strong>Ended:</strong> {new Date(selectedRequest.ended_at).toLocaleString()}</p>
)}
{selectedRequest.tarball && (
<p>
<strong>Tarball:</strong>{" "}
<a
href={tarballUrl(selectedRequest.tarball)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{tarballUrl(selectedRequest.tarball).split("/").pop()}
</a>
</p>
)}
{Array.isArray(selectedRequest.tracks) && selectedRequest.tracks.length > 0 && (
<div>
<strong>Tracks:</strong>
<ul className="list-disc pl-5">
{selectedRequest.tracks.map((t, idx) => {
if (t && typeof t === "object") {
const tid = "track_id" in t ? t.track_id : t.id ?? idx;
const st = t.status ?? "—";
return <li key={idx}>{`${tid}${st}`}</li>;
}
return <li key={idx}>{String(t)}</li>;
})}
</ul>
</div>
)} )}
</div> </div>
) : (
<p>Loading...</p>
)} )}
</Dialog> </Dialog>
</div> </div>
); );
} }

View File

@@ -43,7 +43,7 @@ const { title, description, image } = Astro.props;
class="antialiased flex flex-col items-center justify-center mx-auto mt-2 lg:mt-8 mb-20 lg:mb-40 class="antialiased flex flex-col items-center justify-center mx-auto mt-2 lg:mt-8 mb-20 lg:mb-40
scrollbar-hide"> scrollbar-hide">
<main <main
class="flex-auto min-w-0 mt-2 md:mt-6 flex flex-col px-6 sm:px-4 md:px-0 max-w-2xl w-full"> class="flex-auto min-w-0 mt-2 md:mt-6 flex flex-col px-6 sm:px-4 md:px-0 max-w-3xl w-full">
<noscript> <noscript>
<div style="background: #f44336; color: white; padding: 1em; text-align: center;"> <div style="background: #f44336; color: white; padding: 1em; text-align: center;">
This site requires JavaScript to function. Please enable JavaScript in your browser. This site requires JavaScript to function. Please enable JavaScript in your browser.

View File

@@ -9,10 +9,9 @@ const token = Astro.cookies.get("access_token")?.value;
let user = null; let user = null;
try { try {
const IS_DEV = (ENVIRONMENT==="Dev");
if (token) { if (token) {
user = verifyToken(token); user = verifyToken(token);
if (IS_DEV) { if (user) {
console.log("Verified!", user); console.log("Verified!", user);
} else { } else {
throw Error("Authentication required"); throw Error("Authentication required");