various changes
This commit is contained in:
BIN
public/images/kode.png
Normal file
BIN
public/images/kode.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
@@ -106,6 +106,23 @@ pre {
|
|||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input:-webkit-autofill {
|
||||||
|
-webkit-box-shadow: 0 0 0px 1000px #121212 inset !important; /* match your dark bg */
|
||||||
|
box-shadow: 0 0 0px 1000px #121212 inset !important;
|
||||||
|
-webkit-text-fill-color: white !important; /* match your text color */
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
input:invalid {
|
||||||
|
outline: none; /* or a custom outline */
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="password"]:focus::placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* Remove Safari input shadow on mobile */
|
/* Remove Safari input shadow on mobile */
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="email"] {
|
input[type="email"] {
|
||||||
|
@@ -1,22 +1,24 @@
|
|||||||
// Root.jsx
|
import React, { Suspense, lazy } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import { Player } from './AudioPlayer.jsx';
|
|
||||||
import Memes from './Memes.jsx';
|
import Memes from './Memes.jsx';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
import { JoyUIRootIsland } from './Components.jsx';
|
import { JoyUIRootIsland } from './Components.jsx';
|
||||||
import { PrimeReactProvider } from "primereact/api";
|
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 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';
|
||||||
|
|
||||||
|
const LoginPage = lazy(() => import('./Login.jsx'));
|
||||||
|
const LyricSearch = lazy(() => import('./LyricSearch'));
|
||||||
|
const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.jsx'));
|
||||||
|
const RequestManagement = lazy(() => import('./TRip/RequestManagement.jsx'));
|
||||||
|
const Player = lazy(() => import('./AudioPlayer.jsx'));
|
||||||
|
|
||||||
|
|
||||||
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);
|
||||||
// console.log(opts.children);
|
|
||||||
return (
|
return (
|
||||||
<PrimeReactProvider>
|
<PrimeReactProvider>
|
||||||
<CustomToastContainer
|
<CustomToastContainer
|
||||||
@@ -31,6 +33,7 @@ export default function Root({ child }) {
|
|||||||
color="danger">
|
color="danger">
|
||||||
Work in progress... bugs are to be expected.
|
Work in progress... bugs are to be expected.
|
||||||
</Alert> */}
|
</Alert> */}
|
||||||
|
{child == "LoginPage" && (<LoginPage />)}
|
||||||
{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" />}
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, Suspense, lazy } from "react";
|
||||||
import Hls from "hls.js";
|
|
||||||
import { metaData } from "../config";
|
import { metaData } from "../config";
|
||||||
import Play from "@mui/icons-material/PlayArrow";
|
import Play from "@mui/icons-material/PlayArrow";
|
||||||
import Pause from "@mui/icons-material/Pause";
|
import Pause from "@mui/icons-material/Pause";
|
||||||
import "@styles/player.css";
|
import "@styles/player.css";
|
||||||
|
|
||||||
const API_URL = "https://api.codey.lol";
|
import { API_URL } from "@/config";
|
||||||
|
|
||||||
const STATIONS = {
|
const STATIONS = {
|
||||||
main: { label: "Main" },
|
main: { label: "Main" },
|
||||||
rock: { label: "Rock" },
|
rock: { label: "Rock" },
|
||||||
@@ -15,7 +15,7 @@ const STATIONS = {
|
|||||||
pop: { label: "Pop" },
|
pop: { label: "Pop" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Player() {
|
export default function Player() {
|
||||||
const [activeStation, setActiveStation] = useState("main");
|
const [activeStation, setActiveStation] = useState("main");
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [trackTitle, setTrackTitle] = useState("");
|
const [trackTitle, setTrackTitle] = useState("");
|
||||||
@@ -40,8 +40,14 @@ export function Player() {
|
|||||||
return `${mins}:${secs}`;
|
return `${mins}:${secs}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set page title based on current track and station
|
||||||
|
const setPageTitle = (artist, song) => {
|
||||||
|
document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStation}]`;
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize or switch HLS stream
|
// Initialize or switch HLS stream
|
||||||
const initializeStream = (station) => {
|
const initializeStream = (station) => {
|
||||||
|
import('hls.js').then(({ default: Hls }) => {
|
||||||
const audio = audioElement.current;
|
const audio = audioElement.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`;
|
const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`;
|
||||||
@@ -57,7 +63,6 @@ export function Player() {
|
|||||||
|
|
||||||
// Handle audio load errors
|
// Handle audio load errors
|
||||||
audio.onerror = () => {
|
audio.onerror = () => {
|
||||||
setTrackTitle("Offline");
|
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,13 +81,19 @@ export function Player() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hls = new Hls({ lowLatencyMode: true, abrEnabled: false });
|
const hls = new Hls({
|
||||||
|
lowLatencyMode: true,
|
||||||
|
abrEnabled: false,
|
||||||
|
liveSyncDuration: 3, // seconds behind live edge target
|
||||||
|
liveMaxLatencyDuration: 10, // max allowed latency before catchup
|
||||||
|
liveCatchUpPlaybackRate: 1.05, // playback speed when catching up
|
||||||
|
});
|
||||||
|
|
||||||
hlsInstance.current = hls;
|
hlsInstance.current = hls;
|
||||||
hls.attachMedia(audio);
|
hls.attachMedia(audio);
|
||||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
||||||
setTrackTitle("Offline");
|
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -95,6 +106,7 @@ export function Player() {
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update elapsed time smoothly
|
// Update elapsed time smoothly
|
||||||
@@ -192,6 +204,7 @@ export function Player() {
|
|||||||
|
|
||||||
setLyrics([]);
|
setLyrics([]);
|
||||||
setCurrentLyricIndex(0);
|
setCurrentLyricIndex(0);
|
||||||
|
setPageTitle(trackData.artist, trackData.song);
|
||||||
|
|
||||||
// Fetch lyrics as before
|
// Fetch lyrics as before
|
||||||
const lyricsResponse = await fetch(`${API_URL}/lyric/search`, {
|
const lyricsResponse = await fetch(`${API_URL}/lyric/search`, {
|
||||||
@@ -231,6 +244,16 @@ export function Player() {
|
|||||||
return () => clearInterval(metadataInterval);
|
return () => clearInterval(metadataInterval);
|
||||||
}, [activeStation]);
|
}, [activeStation]);
|
||||||
|
|
||||||
|
const progress = (elapsedTime / trackDuration) * 100;
|
||||||
|
const remaining = trackDuration - elapsedTime;
|
||||||
|
|
||||||
|
const progressColorClass =
|
||||||
|
progress >= 90
|
||||||
|
? "bg-red-500 dark:bg-red-400"
|
||||||
|
: progress >= 75 || remaining <= 20
|
||||||
|
? "bg-yellow-400 dark:bg-yellow-300"
|
||||||
|
: "bg-blue-500 dark:bg-blue-400";
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -267,12 +290,13 @@ export function Player() {
|
|||||||
<p className="music-time__current">{formatTime(elapsedTime)}</p>
|
<p className="music-time__current">{formatTime(elapsedTime)}</p>
|
||||||
<p className="music-time__last">{formatTime(trackDuration - elapsedTime)}</p>
|
<p className="music-time__last">{formatTime(trackDuration - elapsedTime)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 overflow-hidden">
|
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="progress-bar-fill h-full transition-all duration-200 bg-blue-500"
|
className={`h-full transition-all duration-200 ${progressColorClass}`}
|
||||||
style={{ width: `${(elapsedTime / trackDuration) * 100}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`lrc-text ${lyrics.length === 0 ? 'empty' : ''}`}>
|
<div className={`lrc-text ${lyrics.length === 0 ? 'empty' : ''}`}>
|
||||||
{lyrics.map((lyricObj, index) => (
|
{lyrics.map((lyricObj, index) => (
|
||||||
<p
|
<p
|
||||||
|
@@ -7,7 +7,6 @@ interface Props {
|
|||||||
|
|
||||||
import { metaData } from "../config";
|
import { metaData } from "../config";
|
||||||
import { SEO } from "astro-seo";
|
import { SEO } from "astro-seo";
|
||||||
import { getImagePath } from "astro-opengraph-images";
|
|
||||||
import { JoyUIRootIsland } from "./Components"
|
import { JoyUIRootIsland } from "./Components"
|
||||||
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
|
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
|
||||||
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
|
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
|
||||||
@@ -15,12 +14,6 @@ import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher"
|
|||||||
const { title, description = metaData.description, image } = Astro.props;
|
const { title, description = metaData.description, image } = Astro.props;
|
||||||
|
|
||||||
const { url, site } = Astro;
|
const { url, site } = Astro;
|
||||||
const openGraphImageUrl = getImagePath({ url, site });
|
|
||||||
|
|
||||||
// If the image is not provided, use the default image
|
|
||||||
const openGraphImage = image
|
|
||||||
? new URL(image, url.href).href
|
|
||||||
: openGraphImageUrl;
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,7 +27,7 @@ const openGraphImage = image
|
|||||||
basic: {
|
basic: {
|
||||||
title: title || metaData.title,
|
title: title || metaData.title,
|
||||||
type: "website",
|
type: "website",
|
||||||
image: openGraphImageUrl,
|
image: "",
|
||||||
url: url,
|
url: url,
|
||||||
},
|
},
|
||||||
optional: {
|
optional: {
|
||||||
|
159
src/components/Login.jsx
Normal file
159
src/components/Login.jsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { API_URL } from "@/config";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [redirectTo, setRedirectTo] = useState("/");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// On mount, determine where to redirect after login:
|
||||||
|
// 1. Use sessionStorage 'redirectTo' if present
|
||||||
|
// 2. Else use document.referrer if same-origin
|
||||||
|
// 3. Else fallback to "/"
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const savedRedirect = sessionStorage.getItem("redirectTo");
|
||||||
|
if (savedRedirect) {
|
||||||
|
setRedirectTo(savedRedirect);
|
||||||
|
} else if (document.referrer) {
|
||||||
|
const refUrl = new URL(document.referrer);
|
||||||
|
// Only accept same origin referrers for security
|
||||||
|
if (refUrl.origin === window.location.origin) {
|
||||||
|
const pathAndQuery = refUrl.pathname + refUrl.search;
|
||||||
|
setRedirectTo(pathAndQuery);
|
||||||
|
sessionStorage.setItem("redirectTo", pathAndQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fail silently; fallback to "/"
|
||||||
|
console.error("Error determining redirect target:", error);
|
||||||
|
setRedirectTo("/");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append("username", username);
|
||||||
|
formData.append("password", password);
|
||||||
|
formData.append("grant_type", "password");
|
||||||
|
formData.append("scope", "");
|
||||||
|
formData.append("client_id", "");
|
||||||
|
formData.append("client_secret", "");
|
||||||
|
|
||||||
|
const resp = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
credentials: "include", // Important for cookies
|
||||||
|
body: formData.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401) {
|
||||||
|
toast.error("Invalid username or password");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
if (resp.json().detail) {
|
||||||
|
toast.error(`Login failed: ${resp.json().detail}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error("Login failed");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.access_token) {
|
||||||
|
toast.success("Login successful!");
|
||||||
|
|
||||||
|
// Clear stored redirect after use
|
||||||
|
sessionStorage.removeItem("redirectTo");
|
||||||
|
|
||||||
|
// Redirect to stored path or fallback "/"
|
||||||
|
window.location.href = redirectTo || "/";
|
||||||
|
} else {
|
||||||
|
toast.error("Login failed: no access token received");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Network error during login");
|
||||||
|
console.error("Login error:", error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-center bg-gray-50 dark:bg-[#121212] px-4 pt-20 py-50">
|
||||||
|
<div className="max-w-md w-full bg-white dark:bg-[#1E1E1E] rounded-2xl shadow-xl px-10 pb-6">
|
||||||
|
<h2 className="flex flex-col items-center text-3xl font-semibold text-gray-900 dark:text-white mb-8 font-sans">
|
||||||
|
<img className="logo-auth mb-4" src="/images/kode.png" alt="Logo" />
|
||||||
|
Authentication Required
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit} noValidate>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
className="appearance-none block w-full px-4 py-3 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-[#121212] dark:text-white"
|
||||||
|
placeholder="Your username"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="appearance-none block w-full px-4 py-3 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-[#121212] dark:text-white"
|
||||||
|
placeholder="••••••••"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full py-3 bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 text-white rounded-lg font-semibold shadow-md transition-colors ${loading ? "opacity-60 cursor-not-allowed" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loading ? "Signing In..." : "Sign In"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -6,6 +6,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
import Alert from '@mui/joy/Alert';
|
import Alert from '@mui/joy/Alert';
|
||||||
import Box from '@mui/joy/Box';
|
import Box from '@mui/joy/Box';
|
||||||
import Button from "@mui/joy/Button";
|
import Button from "@mui/joy/Button";
|
||||||
|
@@ -2,7 +2,6 @@ import { useEffect, useState, useRef, useCallback } from "react";
|
|||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { Image } from 'primereact/image';
|
import { Image } from 'primereact/image';
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import { API_URL } from '../config';
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
const MEME_API_URL = `${API_URL}/memes/list_memes`;
|
const MEME_API_URL = `${API_URL}/memes/list_memes`;
|
||||||
|
483
src/components/TRip/MediaRequestForm.jsx
Normal file
483
src/components/TRip/MediaRequestForm.jsx
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
import React, { useState, useEffect, useRef, Suspense, lazy } from "react";
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Button } from "@mui/joy";
|
||||||
|
import { Accordion, AccordionTab } from "primereact/accordion";
|
||||||
|
import { AutoComplete } from "primereact/autocomplete";
|
||||||
|
import BreadcrumbNav from "./BreadcrumbNav";
|
||||||
|
|
||||||
|
|
||||||
|
export default function MediaRequestForm() {
|
||||||
|
const [type, setType] = useState("artist");
|
||||||
|
const [selectedArtist, setSelectedArtist] = useState(null);
|
||||||
|
const [artistInput, setArtistInput] = useState("");
|
||||||
|
const [albumInput, setAlbumInput] = useState("");
|
||||||
|
const [trackInput, setTrackInput] = useState("");
|
||||||
|
const [selectedItem, setSelectedItem] = useState(null);
|
||||||
|
const [albums, setAlbums] = useState([]);
|
||||||
|
const [tracksByAlbum, setTracksByAlbum] = useState({});
|
||||||
|
const [selectedTracks, setSelectedTracks] = useState({});
|
||||||
|
const [artistSuggestions, setArtistSuggestions] = useState([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
const debounceTimeout = useRef(null);
|
||||||
|
const autoCompleteRef = useRef(null);
|
||||||
|
|
||||||
|
// Helper fetch wrapper that includes cookies automatically
|
||||||
|
const authFetch = async (url, options = {}) => {
|
||||||
|
const opts = {
|
||||||
|
...options,
|
||||||
|
credentials: "include", // <--- send HttpOnly cookies with requests
|
||||||
|
};
|
||||||
|
return fetch(url, opts);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Fetch artist suggestions for autocomplete
|
||||||
|
const searchArtists = (e) => {
|
||||||
|
const query = e.query.trim();
|
||||||
|
if (!query) {
|
||||||
|
setArtistSuggestions([]);
|
||||||
|
setSelectedArtist(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
|
||||||
|
|
||||||
|
debounceTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
`https://api.codey.lol/trip/get_artists_by_name?artist=${encodeURIComponent(
|
||||||
|
query
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("API error");
|
||||||
|
const data = await res.json();
|
||||||
|
setArtistSuggestions(data);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to fetch artist suggestions.");
|
||||||
|
setArtistSuggestions([]);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
//helpers for string truncation
|
||||||
|
const truncate = (text, maxLen) =>
|
||||||
|
maxLen <= 3
|
||||||
|
? text.slice(0, maxLen)
|
||||||
|
: text.length <= maxLen
|
||||||
|
? text
|
||||||
|
: text.slice(0, maxLen - 3) + '...';
|
||||||
|
|
||||||
|
const artistItemTemplate = (artist) => {
|
||||||
|
if (!artist) return null;
|
||||||
|
return <div>{truncate(artist.artist, 58)}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Handle autocomplete input changes (typing/selecting)
|
||||||
|
const handleArtistChange = (e) => {
|
||||||
|
if (typeof e.value === "string") {
|
||||||
|
setArtistInput(e.value);
|
||||||
|
setSelectedArtist(null);
|
||||||
|
} else if (e.value && typeof e.value === "object") {
|
||||||
|
setSelectedArtist(e.value);
|
||||||
|
setArtistInput(e.value.artist);
|
||||||
|
} else {
|
||||||
|
setArtistInput("");
|
||||||
|
setSelectedArtist(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search button click handler
|
||||||
|
const handleSearch = async () => {
|
||||||
|
setIsSearching(true);
|
||||||
|
if (type === "artist") {
|
||||||
|
if (!selectedArtist) {
|
||||||
|
toast.error("Please select a valid artist from suggestions.");
|
||||||
|
setIsSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedItem(selectedArtist.artist);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
`https://api.codey.lol/trip/get_albums_by_artist_id/${selectedArtist.id}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("API error");
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
data.sort((a, b) =>
|
||||||
|
(b.release_date || "").localeCompare(a.release_date || "")
|
||||||
|
);
|
||||||
|
|
||||||
|
setAlbums(data);
|
||||||
|
setTracksByAlbum({});
|
||||||
|
|
||||||
|
// Set selectedTracks for all albums as null (means tracks loading/not loaded)
|
||||||
|
setSelectedTracks(
|
||||||
|
data.reduce((acc, album) => {
|
||||||
|
acc[album.id] = null;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to fetch albums for artist.");
|
||||||
|
setAlbums([]);
|
||||||
|
setTracksByAlbum({});
|
||||||
|
setSelectedTracks({});
|
||||||
|
}
|
||||||
|
} else if (type === "album") {
|
||||||
|
if (!artistInput.trim() || !albumInput.trim()) {
|
||||||
|
toast.error("Artist and Album are required.");
|
||||||
|
setIsSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedItem(`${artistInput} - ${albumInput}`);
|
||||||
|
setAlbums([]);
|
||||||
|
setTracksByAlbum({});
|
||||||
|
setSelectedTracks({});
|
||||||
|
} else if (type === "track") {
|
||||||
|
if (!artistInput.trim() || !trackInput.trim()) {
|
||||||
|
toast.error("Artist and Track are required.");
|
||||||
|
setIsSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedItem(`${artistInput} - ${trackInput}`);
|
||||||
|
setAlbums([]);
|
||||||
|
setTracksByAlbum({});
|
||||||
|
setSelectedTracks({});
|
||||||
|
}
|
||||||
|
setIsSearching(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrackClick = async (trackId, artist, title) => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`https://api.codey.lol/trip/get_track_by_id/${trackId}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch track URL");
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.stream_url) {
|
||||||
|
const fileResponse = await authFetch(data.stream_url);
|
||||||
|
if (!fileResponse.ok) throw new Error("Failed to fetch track file");
|
||||||
|
|
||||||
|
const blob = await fileResponse.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
|
||||||
|
// Sanitize filename (remove / or other illegal chars)
|
||||||
|
const sanitize = (str) => str.replace(/[\\/:*?"<>|]/g, "_");
|
||||||
|
const filename = `${sanitize(artist)} - ${sanitize(title)}.flac`;
|
||||||
|
|
||||||
|
link.download = filename;
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} else {
|
||||||
|
toast.error("No stream URL returned for this track.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to get track download URL.");
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Sequentially fetch tracks for albums not loaded yet
|
||||||
|
useEffect(() => {
|
||||||
|
if (type !== "artist" || albums.length === 0) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const albumsToFetch = albums.filter((a) => !tracksByAlbum[a.id]);
|
||||||
|
if (albumsToFetch.length === 0) return;
|
||||||
|
|
||||||
|
const fetchTracksSequentially = async () => {
|
||||||
|
for (const album of albumsToFetch) {
|
||||||
|
if (isCancelled) break;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
`https://api.codey.lol/trip/get_tracks_by_album_id/${album.id}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("API error");
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (isCancelled) break;
|
||||||
|
|
||||||
|
setTracksByAlbum((prev) => ({ ...prev, [album.id]: data }));
|
||||||
|
setSelectedTracks((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[album.id]: data.map((t) => String(t.id)),
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Failed to fetch tracks for album ${album.album}.`);
|
||||||
|
setTracksByAlbum((prev) => ({ ...prev, [album.id]: [] }));
|
||||||
|
setSelectedTracks((prev) => ({ ...prev, [album.id]: [] }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTracksSequentially();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [albums, type]);
|
||||||
|
|
||||||
|
// Toggle individual track checkbox
|
||||||
|
const toggleTrack = (albumId, trackId) => {
|
||||||
|
setSelectedTracks((prev) => {
|
||||||
|
const current = new Set(prev[albumId] || []);
|
||||||
|
if (current.has(String(trackId))) current.delete(String(trackId));
|
||||||
|
else current.add(String(trackId));
|
||||||
|
return { ...prev, [albumId]: Array.from(current) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle album checkbox (select/deselect all tracks in album)
|
||||||
|
const toggleAlbum = (albumId) => {
|
||||||
|
const allTracks = tracksByAlbum[albumId]?.map((t) => String(t.id)) || [];
|
||||||
|
setSelectedTracks((prev) => {
|
||||||
|
const current = prev[albumId] || [];
|
||||||
|
const allSelected = current.length === allTracks.length;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[albumId]: allSelected ? [] : [...allTracks],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach scroll fix for autocomplete panel
|
||||||
|
const attachScrollFix = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const panel = document.querySelector(".p-autocomplete-panel");
|
||||||
|
const items = panel?.querySelector(".p-autocomplete-items");
|
||||||
|
if (items) {
|
||||||
|
items.style.maxHeight = "200px";
|
||||||
|
items.style.overflowY = "auto";
|
||||||
|
items.style.overscrollBehavior = "contain";
|
||||||
|
const wheelHandler = (e) => {
|
||||||
|
const delta = e.deltaY;
|
||||||
|
const atTop = items.scrollTop === 0;
|
||||||
|
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
|
||||||
|
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
|
||||||
|
e.preventDefault();
|
||||||
|
} else {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
items.removeEventListener("wheel", wheelHandler);
|
||||||
|
items.addEventListener("wheel", wheelHandler, { passive: false });
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit request handler with progress indicator
|
||||||
|
const handleSubmitRequest = async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
// Example: simulate submission delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
toast.success("Request submitted!");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to submit request.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<AutoComplete
|
||||||
|
ref={autoCompleteRef}
|
||||||
|
value={selectedArtist || artistInput}
|
||||||
|
suggestions={artistSuggestions}
|
||||||
|
field="artist"
|
||||||
|
completeMethod={searchArtists}
|
||||||
|
onChange={handleArtistChange}
|
||||||
|
placeholder="Artist"
|
||||||
|
dropdown
|
||||||
|
className="w-full"
|
||||||
|
inputClassName="w-full px-3 py-2 rounded border border-neutral-300 dark:border-neutral-600 text-black dark:text-white dark:bg-neutral-800"
|
||||||
|
onShow={attachScrollFix}
|
||||||
|
itemTemplate={artistItemTemplate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(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" ? albumInput : trackInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
type === "album" ? setAlbumInput(e.target.value) : setTrackInput(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={type === "album" ? "Album" : "Track"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={handleSearch} disabled={isSearching}>
|
||||||
|
{isSearching ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
|
||||||
|
Searching...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Search"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === "artist" && albums.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Accordion multiple className="mt-4">
|
||||||
|
{albums.map(({ album, id, release_date }) => {
|
||||||
|
const allTracks = tracksByAlbum[id] || [];
|
||||||
|
const selected = selectedTracks[id];
|
||||||
|
|
||||||
|
// Album checkbox is checked if tracks not loaded (selected === null)
|
||||||
|
// or all tracks loaded and all selected
|
||||||
|
const allChecked =
|
||||||
|
selected === null || (selected?.length === allTracks.length && allTracks.length > 0);
|
||||||
|
const someChecked =
|
||||||
|
selected !== null && selected.length > 0 && selected.length < allTracks.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionTab
|
||||||
|
key={id}
|
||||||
|
header={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allChecked}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) el.indeterminate = someChecked;
|
||||||
|
}}
|
||||||
|
onChange={() => toggleAlbum(id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label={`Select all tracks for album ${album}`}
|
||||||
|
/>
|
||||||
|
<span>{album}</span>
|
||||||
|
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{allTracks.length > 0 ? (
|
||||||
|
<ul className="text-sm">
|
||||||
|
{allTracks.map((track) => (
|
||||||
|
<li key={track.id} className="py-1 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected?.includes(String(track.id))}
|
||||||
|
onChange={() => toggleTrack(id, track.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label={`Select track ${track.title}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTrackClick(track.id, selectedArtist.artist, track.title)}
|
||||||
|
className="font-medium text-blue-600 hover:underline cursor-pointer bg-transparent border-none p-0"
|
||||||
|
aria-label={`Download track ${track.title}`}
|
||||||
|
>
|
||||||
|
{track.title}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-neutral-500">{track.audioQuality}</span>
|
||||||
|
{track.version && (
|
||||||
|
<span className="text-xs text-neutral-400">({track.version})</span>
|
||||||
|
)}
|
||||||
|
{track.duration && (
|
||||||
|
<span className="text-xs text-neutral-500 ml-auto tabular-nums">{track.duration}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm italic text-neutral-600 dark:text-neutral-400">
|
||||||
|
{tracksByAlbum[id] ? "No tracks found for this album." : "Loading tracks..."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AccordionTab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSubmitRequest} color="primary" className="mt-4" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
|
||||||
|
Submitting...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Submit Request"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, Suspense, lazy } from "react";
|
||||||
|
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";
|
||||||
import { Button } from "@mui/joy";
|
import { Button } from "@mui/joy";
|
||||||
import { toast } from "react-toastify";
|
|
||||||
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";
|
||||||
@@ -90,7 +90,6 @@ export default function RequestManagement() {
|
|||||||
|
|
||||||
|
|
||||||
const confirmDelete = (requestId) => {
|
const confirmDelete = (requestId) => {
|
||||||
console.log("WHOAA");
|
|
||||||
confirmDialog({
|
confirmDialog({
|
||||||
message: "Are you sure you want to delete this request?",
|
message: "Are you sure you want to delete this request?",
|
||||||
header: "Confirm Delete",
|
header: "Confirm Delete",
|
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { lazy, Suspense } from 'react';
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
|
||||||
|
@@ -1,229 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -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-[640px] 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-2xl 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.
|
||||||
|
@@ -7,11 +7,12 @@ const navItems = [
|
|||||||
{ label: "Home", href: "/" },
|
{ label: "Home", href: "/" },
|
||||||
{ label: "Radio", href: "/radio" },
|
{ label: "Radio", href: "/radio" },
|
||||||
{ label: "Memes", href: "/memes" },
|
{ label: "Memes", href: "/memes" },
|
||||||
{ blockSeparator: true },
|
{ label: "TRip", href: "/TRip", auth: true },
|
||||||
{ label: "Status", href: "https://status.boatson.boats", icon: ExitToApp },
|
{ label: "Status", href: "https://status.boatson.boats", icon: ExitToApp },
|
||||||
{ label: "Git", href: "https://kode.boatson.boats", icon: ExitToApp },
|
{ label: "Git", href: "https://kode.boatson.boats", icon: ExitToApp },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const currentPath = Astro.url.pathname;
|
const currentPath = Astro.url.pathname;
|
||||||
---
|
---
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
@@ -29,20 +30,21 @@ const currentPath = Astro.url.pathname;
|
|||||||
|
|
||||||
<ul class="flex flex-wrap items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
<ul class="flex flex-wrap items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{navItems.map((item, index) => {
|
{navItems.map((item, index) => {
|
||||||
if (item.blockSeparator) {
|
// if (item.blockSeparator) {
|
||||||
return (
|
// return (
|
||||||
<li class="text-neutral-500 dark:text-neutral-500 px-2 select-none" aria-hidden="true">
|
// <li class="text-neutral-500 dark:text-neutral-500 px-2 select-none" aria-hidden="true">
|
||||||
‖
|
// ‖
|
||||||
</li>
|
// </li>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
const isExternal = item.href?.startsWith("http");
|
const isExternal = item.href?.startsWith("http");
|
||||||
|
const isAuthedPath = item.auth ?? false;
|
||||||
const normalize = (url) => url?.replace(/\/+$/, '') || '/';
|
const normalize = (url) => url?.replace(/\/+$/, '') || '/';
|
||||||
const isActive = !isExternal && normalize(item.href) === normalize(currentPath);
|
const isActive = !isExternal && normalize(item.href) === normalize(currentPath);
|
||||||
|
|
||||||
const nextItem = navItems[index + 1];
|
const nextItem = navItems[index + 1];
|
||||||
const shouldShowThinBar = nextItem && !nextItem.blockSeparator;
|
const shouldShowThinBar = nextItem //&& !nextItem.blockSeparator;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -53,7 +55,7 @@ const currentPath = Astro.url.pathname;
|
|||||||
hover:bg-neutral-200 dark:hover:bg-neutral-800
|
hover:bg-neutral-200 dark:hover:bg-neutral-800
|
||||||
${isActive ? "font-semibold underline underline-offset-4" : ""}`}
|
${isActive ? "font-semibold underline underline-offset-4" : ""}`}
|
||||||
target={isExternal ? "_blank" : undefined}
|
target={isExternal ? "_blank" : undefined}
|
||||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
{item.icon === ExitToApp && <ExitToApp className="w-4 h-4" client:load />}
|
{item.icon === ExitToApp && <ExitToApp className="w-4 h-4" client:load />}
|
||||||
|
32
src/pages/TRip/index.astro
Normal file
32
src/pages/TRip/index.astro
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
import MediaRequestForm from "@/components/qs2/MediaRequestForm"
|
||||||
|
import Base from "@/layouts/Base.astro";
|
||||||
|
import Root from "@/components/AppLayout.jsx";
|
||||||
|
import { verifyToken } from "@/utils/jwt";
|
||||||
|
|
||||||
|
const token = Astro.cookies.get("access_token")?.value;
|
||||||
|
let user = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (token) {
|
||||||
|
user = verifyToken(token);
|
||||||
|
if (user) {
|
||||||
|
console.log("Verified!", user);
|
||||||
|
} else {
|
||||||
|
throw Error("Authentication required");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw Error("Authentication required");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return Astro.redirect('/login'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<Base>
|
||||||
|
<section>
|
||||||
|
<div class="prose prose-neutral dark:prose-invert">
|
||||||
|
<Root child="qs2.MediaRequestForm" client:only="react">
|
||||||
|
</Root>
|
||||||
|
</section>
|
||||||
|
</Base>
|
32
src/pages/TRip/requests.astro
Normal file
32
src/pages/TRip/requests.astro
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
import MediaRequestForm from "@/components/TRip/MediaRequestForm"
|
||||||
|
import Base from "@/layouts/Base.astro";
|
||||||
|
import Root from "@/components/AppLayout.jsx";
|
||||||
|
import { verifyToken } from "@/utils/jwt";
|
||||||
|
|
||||||
|
const token = Astro.cookies.get("access_token")?.value;
|
||||||
|
let user = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (token) {
|
||||||
|
user = verifyToken(token);
|
||||||
|
if (user) {
|
||||||
|
console.log("Verified!", user);
|
||||||
|
} else {
|
||||||
|
throw Error("Authentication required");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw Error("Authentication required");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return Astro.redirect('/login'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<Base>
|
||||||
|
<section>
|
||||||
|
<div class="prose prose-neutral dark:prose-invert">
|
||||||
|
<Root child="qs2.RequestManagement" client:only="react">
|
||||||
|
</Root>
|
||||||
|
</section>
|
||||||
|
</Base>
|
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
import MediaRequestForm from "@/components/qs2/MediaRequestForm"
|
import LoginPage from '@/components/Login.jsx';
|
||||||
import Base from "@/layouts/Base.astro";
|
import Base from "@/layouts/Base.astro";
|
||||||
import Root from "@/components/AppLayout.jsx";
|
import Root from "@/components/AppLayout.jsx";
|
||||||
---
|
---
|
||||||
<Base>
|
<Base>
|
||||||
<section>
|
<section>
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
<div class="prose prose-neutral dark:prose-invert">
|
||||||
<Root child="qs2.MediaRequestForm" client:only="react">
|
<Root child="LoginPage" client:only="react">
|
||||||
</Root>
|
</Root>
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
@@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
import MediaRequestForm from "@/components/qs2/MediaRequestForm"
|
|
||||||
import Base from "@/layouts/Base.astro";
|
|
||||||
import Root from "@/components/AppLayout.jsx";
|
|
||||||
---
|
|
||||||
<Base>
|
|
||||||
<section>
|
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
|
||||||
<Root child="qs2.RequestManagement" client:only="react">
|
|
||||||
</Root>
|
|
||||||
</section>
|
|
||||||
</Base>
|
|
41
src/utils/jwt.js
Normal file
41
src/utils/jwt.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
const secretFilePath = path.join(
|
||||||
|
os.homedir(),
|
||||||
|
'.config',
|
||||||
|
'api_jwt_keys.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load and parse keys JSON once at startup
|
||||||
|
const keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
|
||||||
|
|
||||||
|
export function verifyToken(token) {
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.decode(token, { complete: true });
|
||||||
|
if (!decoded?.header?.kid) {
|
||||||
|
throw new Error('No kid in token header');
|
||||||
|
}
|
||||||
|
|
||||||
|
const kid = decoded.header.kid;
|
||||||
|
const key = keyFileData.keys[kid];
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(`Unknown kid: ${kid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify using the correct key and HS256 algo
|
||||||
|
const payload = jwt.verify(token, key, { algorithms: ['HS256'] });
|
||||||
|
return payload;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('JWT verification failed:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user