various changes

This commit is contained in:
2025-08-09 07:10:04 -04:00
parent fbd342c6a7
commit 21796e768e
20 changed files with 886 additions and 342 deletions

View File

@@ -106,6 +106,23 @@ pre {
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 */
input[type="text"],
input[type="email"] {

View File

@@ -1,22 +1,24 @@
// Root.jsx
import { toast } from 'react-toastify';
import { Player } from './AudioPlayer.jsx';
import React, { Suspense, lazy } from 'react';
import Memes from './Memes.jsx';
import { toast } from 'react-toastify';
import { JoyUIRootIsland } from './Components.jsx';
import { PrimeReactProvider } from "primereact/api";
import { usePrimeReactThemeSwitcher } from '@/hooks/usePrimeReactThemeSwitcher.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/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 }) {
window.toast = toast;
const theme = document.documentElement.getAttribute("data-theme")
usePrimeReactThemeSwitcher(theme);
// console.log(opts.children);
return (
<PrimeReactProvider>
<CustomToastContainer
@@ -31,6 +33,7 @@ export default function Root({ child }) {
color="danger">
Work in progress... bugs are to be expected.
</Alert> */}
{child == "LoginPage" && (<LoginPage />)}
{child == "LyricSearch" && (<LyricSearch client:only="react" />)}
{child == "Player" && (<Player client:only="react" />)}
{child == "Memes" && <Memes client:only="react" />}

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, useRef } from "react";
import Hls from "hls.js";
import React, { useState, useEffect, useRef, Suspense, lazy } from "react";
import { metaData } from "../config";
import Play from "@mui/icons-material/PlayArrow";
import Pause from "@mui/icons-material/Pause";
import "@styles/player.css";
const API_URL = "https://api.codey.lol";
import { API_URL } from "@/config";
const STATIONS = {
main: { label: "Main" },
rock: { label: "Rock" },
@@ -15,7 +15,7 @@ const STATIONS = {
pop: { label: "Pop" },
};
export function Player() {
export default function Player() {
const [activeStation, setActiveStation] = useState("main");
const [isPlaying, setIsPlaying] = useState(false);
const [trackTitle, setTrackTitle] = useState("");
@@ -40,60 +40,72 @@ export function Player() {
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
const initializeStream = (station) => {
const audio = audioElement.current;
if (!audio) return;
const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`;
import('hls.js').then(({ default: Hls }) => {
const audio = audioElement.current;
if (!audio) return;
const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`;
// Clean up previous HLS
if (hlsInstance.current) {
hlsInstance.current.destroy();
hlsInstance.current = null;
}
audio.pause();
audio.removeAttribute("src");
audio.load();
// Handle audio load errors
audio.onerror = () => {
setTrackTitle("Offline");
setIsPlaying(false);
};
if (audio.canPlayType("application/vnd.apple.mpegurl")) {
audio.src = streamUrl;
audio.load();
audio.play().then(() => setIsPlaying(true)).catch(() => {
setTrackTitle("Offline");
setIsPlaying(false);
});
return;
}
if (!Hls.isSupported()) {
console.error("HLS not supported");
return;
}
const hls = new Hls({ lowLatencyMode: true, abrEnabled: false });
hlsInstance.current = hls;
hls.attachMedia(audio);
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
hls.on(Hls.Events.MANIFEST_PARSED, () => {
audio.play().then(() => setIsPlaying(true)).catch(() => {
setTrackTitle("Offline");
setIsPlaying(false);
});
});
hls.on(Hls.Events.ERROR, (event, data) => {
console.warn("HLS error:", data);
if (data.fatal) {
hls.destroy();
// Clean up previous HLS
if (hlsInstance.current) {
hlsInstance.current.destroy();
hlsInstance.current = null;
setTrackTitle("Offline");
setIsPlaying(false);
}
audio.pause();
audio.removeAttribute("src");
audio.load();
// Handle audio load errors
audio.onerror = () => {
setIsPlaying(false);
};
if (audio.canPlayType("application/vnd.apple.mpegurl")) {
audio.src = streamUrl;
audio.load();
audio.play().then(() => setIsPlaying(true)).catch(() => {
setTrackTitle("Offline");
setIsPlaying(false);
});
return;
}
if (!Hls.isSupported()) {
console.error("HLS not supported");
return;
}
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;
hls.attachMedia(audio);
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
hls.on(Hls.Events.MANIFEST_PARSED, () => {
audio.play().then(() => setIsPlaying(true)).catch(() => {
setIsPlaying(false);
});
});
hls.on(Hls.Events.ERROR, (event, data) => {
console.warn("HLS error:", data);
if (data.fatal) {
hls.destroy();
hlsInstance.current = null;
setTrackTitle("Offline");
setIsPlaying(false);
}
});
});
};
@@ -192,6 +204,7 @@ export function Player() {
setLyrics([]);
setCurrentLyricIndex(0);
setPageTitle(trackData.artist, trackData.song);
// Fetch lyrics as before
const lyricsResponse = await fetch(`${API_URL}/lyric/search`, {
@@ -231,6 +244,16 @@ export function Player() {
return () => clearInterval(metadataInterval);
}, [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 (
<>
@@ -267,12 +290,13 @@ export function Player() {
<p className="music-time__current">{formatTime(elapsedTime)}</p>
<p className="music-time__last">{formatTime(trackDuration - elapsedTime)}</p>
</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
className="progress-bar-fill h-full transition-all duration-200 bg-blue-500"
style={{ width: `${(elapsedTime / trackDuration) * 100}%` }}
/>
className={`h-full transition-all duration-200 ${progressColorClass}`}
style={{ width: `${progress}%` }}
></div>
</div>
<div className={`lrc-text ${lyrics.length === 0 ? 'empty' : ''}`}>
{lyrics.map((lyricObj, index) => (
<p

View File

@@ -7,7 +7,6 @@ interface Props {
import { metaData } from "../config";
import { SEO } from "astro-seo";
import { getImagePath } from "astro-opengraph-images";
import { JoyUIRootIsland } from "./Components"
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
@@ -15,12 +14,6 @@ import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher"
const { title, description = metaData.description, image } = Astro.props;
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: {
title: title || metaData.title,
type: "website",
image: openGraphImageUrl,
image: "",
url: url,
},
optional: {

159
src/components/Login.jsx Normal file
View 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>
);
}

View File

@@ -6,6 +6,7 @@ import React, {
useRef,
useState,
} from "react";
import { toast } from 'react-toastify';
import Alert from '@mui/joy/Alert';
import Box from '@mui/joy/Box';
import Button from "@mui/joy/Button";

View File

@@ -2,7 +2,6 @@ import { useEffect, useState, useRef, useCallback } from "react";
import { ProgressSpinner } from 'primereact/progressspinner';
import { Dialog } from 'primereact/dialog';
import { Image } from 'primereact/image';
import { toast } from 'react-toastify';
import { API_URL } from '../config';
const MEME_API_URL = `${API_URL}/memes/list_memes`;
@@ -43,8 +42,8 @@ const Memes = () => {
const imageObjects = newMemes.map(m => ({
id: m.id,
timestamp: new Date(m.timestamp * 1000)
.toString().split(" ")
.splice(0, 4).join(" "),
.toString().split(" ")
.splice(0, 4).join(" "),
url: `${BASE_IMAGE_URL}/${m.id}.png`,
}));
@@ -118,14 +117,14 @@ const Memes = () => {
src={selectedImage.url}
alt={`meme-${selectedImage.id}`}
style={{
maxWidth: '100%',
maxHeight: '70vh', // restrict height to viewport height
objectFit: 'contain',
display: 'block',
margin: '0 auto',
borderRadius: '6px',
maxWidth: '100%',
maxHeight: '70vh', // restrict height to viewport height
objectFit: 'contain',
display: 'block',
margin: '0 auto',
borderRadius: '6px',
}}
/>
/>
)}
</Dialog>

View 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>
);
}

View File

@@ -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 { Column } from "primereact/column";
import { Dropdown } from "primereact/dropdown";
import { Button } from "@mui/joy";
import { toast } from "react-toastify";
import { Dialog } from "primereact/dialog";
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
import BreadcrumbNav from "./BreadcrumbNav";
@@ -90,7 +90,6 @@ export default function RequestManagement() {
const confirmDelete = (requestId) => {
console.log("WHOAA");
confirmDialog({
message: "Are you sure you want to delete this request?",
header: "Confirm Delete",

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { lazy, Suspense } from 'react';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';

View File

@@ -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>
);
}

View File

@@ -43,13 +43,13 @@ 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
scrollbar-hide">
<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>
<div style="background: #f44336; color: white; padding: 1em; text-align: center;">
This site requires JavaScript to function. Please enable JavaScript in your browser.
</div>
</noscript>
<Navbar />
<Navbar />
<slot />
<Footer />
</main>

View File

@@ -7,11 +7,12 @@ const navItems = [
{ label: "Home", href: "/" },
{ label: "Radio", href: "/radio" },
{ label: "Memes", href: "/memes" },
{ blockSeparator: true },
{ label: "TRip", href: "/TRip", auth: true },
{ label: "Status", href: "https://status.boatson.boats", icon: ExitToApp },
{ label: "Git", href: "https://kode.boatson.boats", icon: ExitToApp },
];
const currentPath = Astro.url.pathname;
---
<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">
{navItems.map((item, index) => {
if (item.blockSeparator) {
return (
<li class="text-neutral-500 dark:text-neutral-500 px-2 select-none" aria-hidden="true">
</li>
);
}
// if (item.blockSeparator) {
// return (
// <li class="text-neutral-500 dark:text-neutral-500 px-2 select-none" aria-hidden="true">
//
// </li>
// );
// }
const isExternal = item.href?.startsWith("http");
const isAuthedPath = item.auth ?? false;
const normalize = (url) => url?.replace(/\/+$/, '') || '/';
const isActive = !isExternal && normalize(item.href) === normalize(currentPath);
const nextItem = navItems[index + 1];
const shouldShowThinBar = nextItem && !nextItem.blockSeparator;
const shouldShowThinBar = nextItem //&& !nextItem.blockSeparator;
return (
<>
@@ -53,7 +55,7 @@ const currentPath = Astro.url.pathname;
hover:bg-neutral-200 dark:hover:bg-neutral-800
${isActive ? "font-semibold underline underline-offset-4" : ""}`}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
rel={(isExternal || isAuthedPath) ? "external" : undefined}
>
{item.label}
{item.icon === ExitToApp && <ExitToApp className="w-4 h-4" client:load />}

View 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>

View 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>

View File

@@ -1,12 +1,12 @@
---
import MediaRequestForm from "@/components/qs2/MediaRequestForm"
import LoginPage from '@/components/Login.jsx';
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.MediaRequestForm" client:only="react">
<Root child="LoginPage" client:only="react">
</Root>
</section>
</Base>
</Base>

View File

@@ -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
View 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;
}
}