dicking around

This commit is contained in:
2025-07-31 19:28:59 -04:00
parent bde9fda78f
commit a631691fd6
7 changed files with 630 additions and 12 deletions

View File

@@ -250,3 +250,4 @@ Custom
.d-light > * { .d-light > * {
background-color: rgba(255, 255, 255, 0.9); background-color: rgba(255, 255, 255, 0.9);
} }

View File

@@ -7,10 +7,12 @@ import { PrimeReactProvider } from "primereact/api";
import { usePrimeReactThemeSwitcher } from '@/hooks/usePrimeReactThemeSwitcher.jsx'; import { usePrimeReactThemeSwitcher } from '@/hooks/usePrimeReactThemeSwitcher.jsx';
import CustomToastContainer from '../components/ToastProvider.jsx'; import CustomToastContainer from '../components/ToastProvider.jsx';
import LyricSearch from './LyricSearch.jsx'; import LyricSearch from './LyricSearch.jsx';
import MediaRequestForm from './qs2/MediaRequestForm.jsx';
import RequestManagement from './qs2/RequestManagement.jsx';
import 'primereact/resources/themes/bootstrap4-light-blue/theme.css'; import 'primereact/resources/themes/bootstrap4-light-blue/theme.css';
import 'primereact/resources/primereact.min.css'; import 'primereact/resources/primereact.min.css';
export default function Root({child}) { export default function Root({ child }) {
window.toast = toast; window.toast = toast;
const theme = document.documentElement.getAttribute("data-theme") const theme = document.documentElement.getAttribute("data-theme")
usePrimeReactThemeSwitcher(theme); usePrimeReactThemeSwitcher(theme);
@@ -20,7 +22,7 @@ export default function Root({child}) {
<CustomToastContainer <CustomToastContainer
theme={theme} theme={theme}
newestOnTop={true} newestOnTop={true}
closeOnClick={true}/> closeOnClick={true} />
<JoyUIRootIsland> <JoyUIRootIsland>
{/* <Alert {/* <Alert
className="alert" className="alert"
@@ -32,6 +34,8 @@ export default function Root({child}) {
{child == "LyricSearch" && (<LyricSearch client:only="react" />)} {child == "LyricSearch" && (<LyricSearch client:only="react" />)}
{child == "Player" && (<Player client:only="react" />)} {child == "Player" && (<Player client:only="react" />)}
{child == "Memes" && <Memes client:only="react" />} {child == "Memes" && <Memes client:only="react" />}
{child == "qs2.MediaRequestForm" && <MediaRequestForm client:only="react" />}
{child == "qs2.RequestManagement" && <RequestManagement client:only="react" />}
</JoyUIRootIsland> </JoyUIRootIsland>
</PrimeReactProvider> </PrimeReactProvider>
); );

View File

@@ -0,0 +1,25 @@
import React from "react";
export default function BreadcrumbNav({ currentPage }) {
const pages = [
{ key: "request", label: "Request Media", href: "new" },
{ key: "management", label: "Manage Requests", href: "requests" },
];
return (
<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) => (
<React.Fragment key={key}>
<a
href={href}
className={`hover:underline ${currentPage === key ? "font-bold underline" : ""}`}
aria-current={currentPage === key ? "page" : undefined}
>
{label}
</a>
{i < pages.length - 1 && <span aria-hidden="true">/</span>}
</React.Fragment>
))}
</nav>
);
}

View File

@@ -0,0 +1,229 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@mui/joy";
import { toast } from "react-toastify";
import { Checkbox } from "primereact/checkbox";
import { Accordion, AccordionTab } from "primereact/accordion";
import BreadcrumbNav from "./BreadcrumbNav";
export default function MediaRequestForm() {
const [type, setType] = useState("artist");
const [artist, setArtist] = useState("");
const [album, setAlbum] = useState("");
const [track, setTrack] = useState("");
const [selectedItem, setSelectedItem] = useState(null);
const [albums, setAlbums] = useState([]);
const [selectedAlbums, setSelectedAlbums] = useState([]);
const [tracksByAlbum, setTracksByAlbum] = useState({});
const [selectedTracks, setSelectedTracks] = useState({});
const handleSearch = async () => {
if (type === "artist" && !artist.trim()) {
toast.error("Artist is required.");
return;
}
if (type === "album" && (!artist.trim() || !album.trim())) {
toast.error("Artist and Album are required.");
return;
}
if (type === "track" && (!artist.trim() || !track.trim())) {
toast.error("Artist and Track are required.");
return;
}
setSelectedItem(
type === "artist"
? artist
: type === "album"
? `${artist} - ${album}`
: `${artist} - ${track}`
);
// TODO: Fetch albums or tracks based on type and inputs
if (type === "artist") {
const fakeAlbums = ["Album A", "Album B"];
setAlbums(fakeAlbums);
setSelectedAlbums(fakeAlbums);
setTracksByAlbum({
"Album A": ["Track A1", "Track A2"],
"Album B": ["Track B1", "Track B2"],
});
setSelectedTracks({
"Album A": ["Track A1", "Track A2"],
"Album B": ["Track B1", "Track B2"],
});
} else {
setAlbums([]);
setTracksByAlbum({});
setSelectedTracks({});
}
};
const toggleTrack = (album, track) => {
setSelectedTracks((prev) => {
const tracks = new Set(prev[album] || []);
if (tracks.has(track)) {
tracks.delete(track);
} else {
tracks.add(track);
}
return { ...prev, [album]: Array.from(tracks) };
});
};
// For managing indeterminate state of album-level checkboxes
function AlbumCheckbox({ albumName }) {
const checkboxRef = useRef(null);
const allTracks = tracksByAlbum[albumName] || [];
const selected = selectedTracks[albumName] || [];
useEffect(() => {
if (!checkboxRef.current) return;
const isChecked = selected.length === allTracks.length && allTracks.length > 0;
const isIndeterminate = selected.length > 0 && selected.length < allTracks.length;
checkboxRef.current.checked = isChecked;
checkboxRef.current.indeterminate = isIndeterminate;
}, [selected, allTracks]);
const onChange = () => {
const allSelected = selected.length === allTracks.length;
setSelectedTracks((prev) => ({
...prev,
[albumName]: allSelected ? [] : [...allTracks],
}));
};
return (
<input
type="checkbox"
ref={checkboxRef}
onChange={onChange}
onClick={(e) => e.stopPropagation()} // <-- Prevent accordion toggle
className="cursor-pointer"
aria-label={`Select all tracks for album ${albumName}`}
/>
);
}
const handleSubmit = async () => {
if (!selectedItem) {
toast.error("Please perform a search before submitting.");
return;
}
// TODO: Send request to backend
toast.success("Request submitted!");
};
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">
<style>{`
.p-accordion-tab {
background-color: #ffffff;
color: #000000;
}
[data-theme="dark"] .p-accordion-tab {
background-color: #1e1e1e;
color: #ffffff;
}
[data-theme="dark"] .p-accordion-header .p-accordion-header-link {
background-color: #1e1e1e !important;
color: #ffffff !important;
}
[data-theme="dark"] .p-accordion-content {
background-color: #2a2a2a;
color: #ffffff;
}
`}</style>
<BreadcrumbNav currentPage="request" />
<div className="flex flex-col gap-6">
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input
type="radio"
value="artist"
checked={type === "artist"}
onChange={() => setType("artist")}
/>
Artist
</label>
<label className="flex items-center gap-2">
<input
type="radio"
value="album"
checked={type === "album"}
onChange={() => setType("album")}
/>
Album
</label>
<label className="flex items-center gap-2">
<input
type="radio"
value="track"
checked={type === "track"}
onChange={() => setType("track")}
/>
Track
</label>
</div>
<div className="flex flex-col gap-4">
<input
type="text"
className="w-full dark:bg-neutral-800 dark:text-white border border-neutral-300 dark:border-neutral-600 rounded px-3 py-2"
value={artist}
onChange={(e) => setArtist(e.target.value)}
placeholder="Artist"
/>
{(type === "album" || type === "track") && (
<input
type="text"
className="w-full dark:bg-neutral-800 dark:text-white border border-neutral-300 dark:border-neutral-600 rounded px-3 py-2"
value={type === "album" ? album : track}
onChange={(e) =>
type === "album" ? setAlbum(e.target.value) : setTrack(e.target.value)
}
placeholder={type === "album" ? "Album" : "Track"}
/>
)}
<Button onClick={handleSearch}>Search</Button>
</div>
{type === "artist" && albums.length > 0 && (
<>
<Accordion multiple className="mt-4">
{albums.map((albumName) => (
<AccordionTab
key={albumName}
header={
<div className="flex items-center gap-2">
<AlbumCheckbox albumName={albumName} />
<span>{albumName}</span>
</div>
}
>
<div className="flex flex-col gap-2">
{(tracksByAlbum[albumName] || []).map((track) => (
<label key={track} className="flex items-center gap-3">
<Checkbox
inputId={`${albumName}-${track}`}
checked={selectedTracks[albumName]?.includes(track)}
onChange={() => toggleTrack(albumName, track)}
/>
<span>{track}</span>
</label>
))}
</div>
</AccordionTab>
))}
</Accordion>
<div className="flex justify-end">
<Button onClick={handleSubmit} color="primary" className="mt-4">
Submit Request
</Button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,333 @@
import React, { useState, useEffect } from "react";
import { DataTable } from "primereact/datatable";
import { Column } from "primereact/column";
import { Dropdown } from "primereact/dropdown";
import { Button } from "@mui/joy";
import { toast } from "react-toastify";
import { confirmDialog } from "primereact/confirmdialog";
import BreadcrumbNav from "./BreadcrumbNav";
const STATUS_OPTIONS = ["Pending", "Completed", "Failed"];
const TYPE_OPTIONS = ["Artist", "Album", "Track"];
const initialRequests = [
{
id: 1,
type: "Artist",
artist: "Bring Me The Horizon",
album: "",
track: "",
status: "Pending",
},
{
id: 2,
type: "Album",
artist: "Pink Floyd",
album: "Dark Side of the Moon",
track: "",
status: "Completed",
},
{
id: 3,
type: "Track",
artist: "We Butter The Bread With Butter",
album: "Das Album",
track: "20 km/h",
status: "Failed",
},
{
id: 4,
type: "Track",
artist: "Chappell Roan",
album: "Pink Pony Club",
track: "Pink Pony Club",
status: "Completed",
},
];
export default function RequestManagement() {
const [requests, setRequests] = useState(initialRequests);
const [filterType, setFilterType] = useState(null);
const [filterStatus, setFilterStatus] = useState(null);
const [filteredRequests, setFilteredRequests] = useState(initialRequests);
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 confirmDelete = (requestId) => {
confirmDialog({
message: "Are you sure you want to delete this request?",
header: "Confirm Delete",
icon: "pi pi-exclamation-triangle",
accept: () => deleteRequest(requestId),
});
};
const deleteRequest = (requestId) => {
setRequests((prev) => prev.filter((r) => r.id !== requestId));
toast.success("Request deleted");
};
const statusBodyTemplate = (rowData) => {
let colorClass = "";
switch (rowData.status) {
case "Pending":
colorClass = "bg-yellow-300 text-yellow-900";
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 (
<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 */
[data-theme="dark"] .p-datatable {
background-color: #121212;
color: #f0f0f0; /* brighter text */
border-color: #333;
}
[data-theme="dark"] .p-datatable-header {
background-color: #1f1f1f;
color: #f5f5f5; /* brighter */
border-bottom: 1px solid #444;
}
[data-theme="dark"] .p-datatable-thead > tr > th {
background-color: #222222;
color: #f5f5f5; /* brighter */
border-bottom: 1px solid #555;
}
[data-theme="dark"] .p-datatable-tbody > tr {
background-color: #1a1a1a;
border-bottom: 1px solid #333;
transition: background-color 0.2s ease;
color: #f0f0f0; /* brighter text */
}
/* Zebra stripes */
[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) {
background-color: #181818;
color: #f0f0f0;
}
/* Hover effect */
[data-theme="dark"] .p-datatable-tbody > tr:hover {
background-color: #333333;
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;
}
[data-theme="dark"] .p-button.p-button-danger {
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 {
background-color: #121212;
color: #f0f0f0;
border-top: 1px solid #333;
}
[data-theme="dark"] .p-paginator .p-paginator-page,
[data-theme="dark"] .p-paginator .p-paginator-next,
[data-theme="dark"] .p-paginator .p-paginator-prev {
color: #ccc;
border: none;
background: transparent;
}
[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: #333;
color: #fff;
border-radius: 6px;
}
[data-theme="dark"] .p-paginator .p-highlight {
background-color: #3b82f6; /* Tailwind blue-500 */
color: white;
border-radius: 6px;
}
.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 */
[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;
}`}</style>
<BreadcrumbNav currentPage="management" />
<h2 className="text-3xl font-semibold" style={{ marginTop: 0 }}>
Media Request Management
</h2>
<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
value={filterStatus}
options={[
{ label: "All Statuses", value: null },
...STATUS_OPTIONS.map((s) => ({ label: s, value: s })),
]}
onChange={(e) => setFilterStatus(e.value)}
placeholder="Filter by Status"
className="min-w-[180px]"
/>
</div>
<DataTable
value={filteredRequests}
paginator
rows={10}
removableSort
sortMode="multiple"
emptyMessage="No requests found."
rowClassName="!py-4"
className="min-w-full bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
>
<Column field="id" header="ID" sortable style={{ width: "5rem" }} />
<Column field="type" header="Type" sortable />
<Column field="artist" header="Artist" sortable />
<Column field="album" header="Album" sortable />
<Column field="track" header="Track" sortable />
<Column
field="status"
header="Status"
body={statusBodyTemplate}
style={{ width: "10rem", textAlign: "center" }}
sortable
/>
<Column
body={actionBodyTemplate}
header="Actions"
style={{ width: "9rem", textAlign: "center" }}
/>
</DataTable>
</div>
);
}

13
src/pages/qs2/new.astro Normal file
View File

@@ -0,0 +1,13 @@
---
import MediaRequestForm from "@/components/qs2/MediaRequestForm"
import Base from "@/layouts/Base.astro";
import Root from "@/components/AppLayout.jsx";
import "@styles/MemeGrid.css";
---
<Base>
<section>
<div class="prose prose-neutral dark:prose-invert">
<Root child="qs2.MediaRequestForm" client:only="react">
</Root>
</section>
</Base>

View File

@@ -0,0 +1,13 @@
---
import MediaRequestForm from "@/components/qs2/MediaRequestForm"
import Base from "@/layouts/Base.astro";
import Root from "@/components/AppLayout.jsx";
import "@styles/MemeGrid.css";
---
<Base>
<section>
<div class="prose prose-neutral dark:prose-invert">
<Root child="qs2.RequestManagement" client:only="react">
</Root>
</section>
</Base>