From 21796e768e206a600a08cd9ead307313ad290cd2 Mon Sep 17 00:00:00 2001 From: codey Date: Sat, 9 Aug 2025 07:10:04 -0400 Subject: [PATCH] various changes --- public/images/kode.png | Bin 0 -> 1812 bytes src/assets/styles/global.css | 17 + src/components/AppLayout.jsx | 17 +- src/components/AudioPlayer.jsx | 138 ++--- src/components/BaseHead.astro | 9 +- src/components/Login.jsx | 159 ++++++ src/components/LyricSearch.jsx | 1 + src/components/Memes.jsx | 19 +- .../{qs2 => TRip}/BreadcrumbNav.jsx | 0 src/components/TRip/MediaRequestForm.jsx | 483 ++++++++++++++++++ .../{qs2 => TRip}/RequestManagement.jsx | 5 +- src/components/ToastProvider.jsx | 2 +- src/components/qs2/MediaRequestForm.jsx | 229 --------- src/layouts/Base.astro | 4 +- src/layouts/Nav.astro | 22 +- src/pages/TRip/index.astro | 32 ++ src/pages/TRip/requests.astro | 32 ++ src/pages/{qs2/index.astro => login.astro} | 6 +- src/pages/qs2/requests.astro | 12 - src/utils/jwt.js | 41 ++ 20 files changed, 886 insertions(+), 342 deletions(-) create mode 100644 public/images/kode.png create mode 100644 src/components/Login.jsx rename src/components/{qs2 => TRip}/BreadcrumbNav.jsx (100%) create mode 100644 src/components/TRip/MediaRequestForm.jsx rename src/components/{qs2 => TRip}/RequestManagement.jsx (99%) delete mode 100644 src/components/qs2/MediaRequestForm.jsx create mode 100644 src/pages/TRip/index.astro create mode 100644 src/pages/TRip/requests.astro rename src/pages/{qs2/index.astro => login.astro} (59%) delete mode 100644 src/pages/qs2/requests.astro create mode 100644 src/utils/jwt.js diff --git a/public/images/kode.png b/public/images/kode.png new file mode 100644 index 0000000000000000000000000000000000000000..032b95f99fce84869f73d02424dc33760f346df2 GIT binary patch literal 1812 zcmZ{lcR1UN8pnTBY7|9}nCDuxf;6#u%+?;oMeSK5R*fJWiE*@AJ<1WSG#sNUu_Y9d z*1FoN+M_XImUE3pjoLJ*lk?o?x%Z!Y{&?T-^N!E+{qvh)Z)d^BBhCW=0H2kmsUu5a ze-#%8tDjwh^Rh%bz|zqc05A#wkoo`sn5|iHr;zU{5nsG8N2!Jn0mF$=70|4mQ%GAiot*dz|A`9m#%(=awp|P>9s!9nr z!kgt#NAWM)Ybfu*&E)&LY-KwuX%nJWW$80uT3L0*PMX4j30zwt#NXJ+p7N_vp(Qs- zJ~Y(+iISVoEC~M)U%mm=8`u~;_Ir*#{5?4!<4|8JX!TR<$a&GZ&e5FSWvNCE;-pQr zW38h!(bIh4{IYaJDPlFBU?-&GJ*-FQmMK2>L^W^i-T!$-I6Yx^IMJwSUu|0;H|*{P z3L(05CjDdlx2OB$f|AI#qZM<_PnUO}7YAj1O6cdQG)L%ur+7pTh77u*X&`iRT5n$; zgC?(`)JQl)OE~k{E#KT5Ni-(EaR^R=?pM=qP>w6L=Xq#bupX1u^i}2td?E=%k2oX^ zS}yBXYwUk{eQ_av<|Jjyr_ZR>s_AT8IG8lHNJcti4sf z2rIujQR6BH8yI`8ex-?6v{L*EbKI{ZymVGGl3q5{_z_|oR?fk$tX1I=kv0u^+cgvL z?I#12ne(g=eN8f}%X5yxu2OPL9%3 zQe9kSC6@F*zc$u9c_CcBtIRg;Ml^I}92Rx#KB0p)F#|y3%!=I0BDy|VufX=mQ-#gu zl`7L`TjXRkB!;p~Oa1pi>kr8#AvNvbM4~ABz??UPN(kS!rzm0~b}Q<2{L^*!`}l8z zClp$G^m|m1mNSi2rnP~mExz#0dAG;V!r+D|E73}aF0MH5QX?XU+xOM`Q*{+`_r-kn zOIjcB-t;etDXOJ*W40%^Txy~9$M>XK!4f6@4axGU2!tB(mPb7lizbElA=QWDJL9~` zbyFdWABArA7d3)(u6R2IKP0+fDLY+UR1d!rmn+0ph;>t$7mstr0~3(WCbSCgtQ;I! z4K~Mh>y9&=%ZGGwubLQE)X<7=l3VSYNBGxIEd*TXjO=$D>{i^0Nj*_>Ts`aKU_}+8 zzrnh^R7jpEA@P&VQz7X3u11gSV!uDXbqctB16R9s#k3Im*(<;G0oLqWuddi`aW=!2 z2gR)Ajt|-Dv6Yb%`HmStp#25C_FkvSyzx7FK&%|P%(nGP3(V%8_oIt|^tIa@jO_ke z!&6?_R2-@|w3z#e=K@WiJI`oVaXGk6AB5FWn7E= z3)hXBvB!^h1ee&tG(E(Oi^pzfy%-Txc^gcMd8esJ;1Y6aneEhax5v}sz&f9yv2w0L zLKRGko`F!dBwl568K2YKpgkool9`HIO`sW9zeL#!JFNKCcBNI_i4qjy54~hn5J)a> zkj(O7=PscPm*u?Bj@rL~V#aOg^ANaVi0bo-_BpDVZHqs5XGTLL6Q-{d#cOhKNRwx2 zK?fyuU6xq-+%-8FvGsP>O(nP&E|>q8iPHSuZbm;a2eze^*@LYASkC|)OIPz%{pR(!;Mr)9|D z_wMq=IBtW2x%4B7crE>cNf;IV?&}F>znGzlUtdn}l;8Dsxq1)m?cu7Ji47S5bJmMb zKr8lFH2F~66{w0lF|Bb7T;4NWZZ6JwbnS6n4(Vy+hx%|xCxP#?md*}QYMf^%SbpoF3U^)UjI)c{XMrF8jwR#h0 zQ#^~*rxQ9Uo+S?*XOif6HjzQW!1Q?!RmZ3Z5*g(9;XT18oXou%($F(l%E$K$$<=eq zC{*l5G^V!>r#WP zfb~=A6tQ5wPC66L?kY*8OI-iu8H||+^lc-!#0)Uz)BQJ+zFnhN!o-EArh8biAdfP0 zLk0Sw5V`>|2$lfqFm+8en1&i$+euwr7p9@B0aJm&bYZZrNy)$B=6^s$RA6Wj`tO0p p%rbKpp!8n{RA>Yu7UdU#{67r*KPDz?*=j5XurjkVtuwxJ|4)gzPhJ23 literal 0 HcmV?d00001 diff --git a/src/assets/styles/global.css b/src/assets/styles/global.css index 09c7888..41c18d6 100644 --- a/src/assets/styles/global.css +++ b/src/assets/styles/global.css @@ -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"] { diff --git a/src/components/AppLayout.jsx b/src/components/AppLayout.jsx index fd73c10..6e87b59 100644 --- a/src/components/AppLayout.jsx +++ b/src/components/AppLayout.jsx @@ -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 ( Work in progress... bugs are to be expected. */} + {child == "LoginPage" && ()} {child == "LyricSearch" && ()} {child == "Player" && ()} {child == "Memes" && } diff --git a/src/components/AudioPlayer.jsx b/src/components/AudioPlayer.jsx index 0042ea0..fe78c50 100644 --- a/src/components/AudioPlayer.jsx +++ b/src/components/AudioPlayer.jsx @@ -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() {

{formatTime(elapsedTime)}

{formatTime(trackDuration - elapsedTime)}

-
+
+ className={`h-full transition-all duration-200 ${progressColorClass}`} + style={{ width: `${progress}%` }} + >
+
{lyrics.map((lyricObj, index) => (

{ + 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 ( +

+
+

+ Logo + Authentication Required +

+ +
+
+ + 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} + /> +
+ +
+ + 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} + /> +
+ + +
+
+
+ ); +} diff --git a/src/components/LyricSearch.jsx b/src/components/LyricSearch.jsx index 1d7cbf6..5f7755a 100644 --- a/src/components/LyricSearch.jsx +++ b/src/components/LyricSearch.jsx @@ -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"; diff --git a/src/components/Memes.jsx b/src/components/Memes.jsx index 447ae95..63e7c47 100644 --- a/src/components/Memes.jsx +++ b/src/components/Memes.jsx @@ -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', }} - /> + /> )} diff --git a/src/components/qs2/BreadcrumbNav.jsx b/src/components/TRip/BreadcrumbNav.jsx similarity index 100% rename from src/components/qs2/BreadcrumbNav.jsx rename to src/components/TRip/BreadcrumbNav.jsx diff --git a/src/components/TRip/MediaRequestForm.jsx b/src/components/TRip/MediaRequestForm.jsx new file mode 100644 index 0000000..a909621 --- /dev/null +++ b/src/components/TRip/MediaRequestForm.jsx @@ -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
{truncate(artist.artist, 58)}
; + }; + + + // 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 ( +
+ + + + +
+
+ + + +
+ +
+ + + {(type === "album" || type === "track") && ( + + type === "album" ? setAlbumInput(e.target.value) : setTrackInput(e.target.value) + } + placeholder={type === "album" ? "Album" : "Track"} + /> + )} + + +
+ + {type === "artist" && albums.length > 0 && ( + <> + + {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 ( + + { + if (el) el.indeterminate = someChecked; + }} + onChange={() => toggleAlbum(id)} + onClick={(e) => e.stopPropagation()} + className="cursor-pointer" + aria-label={`Select all tracks for album ${album}`} + /> + {album} + ({release_date}) +
+ } + > + {allTracks.length > 0 ? ( +
    + {allTracks.map((track) => ( +
  • + toggleTrack(id, track.id)} + className="cursor-pointer" + aria-label={`Select track ${track.title}`} + /> + + {track.audioQuality} + {track.version && ( + ({track.version}) + )} + {track.duration && ( + {track.duration} + )} +
  • + ))} + +
+ ) : ( +
+ {tracksByAlbum[id] ? "No tracks found for this album." : "Loading tracks..."} +
+ )} + + ); + })} + + +
+ +
+ + )} +
+
+ ); +} diff --git a/src/components/qs2/RequestManagement.jsx b/src/components/TRip/RequestManagement.jsx similarity index 99% rename from src/components/qs2/RequestManagement.jsx rename to src/components/TRip/RequestManagement.jsx index 15e6929..45632a4 100644 --- a/src/components/qs2/RequestManagement.jsx +++ b/src/components/TRip/RequestManagement.jsx @@ -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", diff --git a/src/components/ToastProvider.jsx b/src/components/ToastProvider.jsx index 0dcf794..b5e80aa 100644 --- a/src/components/ToastProvider.jsx +++ b/src/components/ToastProvider.jsx @@ -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'; diff --git a/src/components/qs2/MediaRequestForm.jsx b/src/components/qs2/MediaRequestForm.jsx deleted file mode 100644 index 3908df5..0000000 --- a/src/components/qs2/MediaRequestForm.jsx +++ /dev/null @@ -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 ( - 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 ( -
- - -
-
- - - -
- -
- setArtist(e.target.value)} - placeholder="Artist" - /> - {(type === "album" || type === "track") && ( - - type === "album" ? setAlbum(e.target.value) : setTrack(e.target.value) - } - placeholder={type === "album" ? "Album" : "Track"} - /> - )} - -
- - {type === "artist" && albums.length > 0 && ( - <> - - {albums.map((albumName) => ( - - - {albumName} -
- } - > -
- {(tracksByAlbum[albumName] || []).map((track) => ( - - ))} -
- - ))} - - -
- -
- - )} -
-
- ); -} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index afd801a..970204f 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -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">
+ 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"> - +
diff --git a/src/layouts/Nav.astro b/src/layouts/Nav.astro index bfe0940..239713b 100644 --- a/src/layouts/Nav.astro +++ b/src/layouts/Nav.astro @@ -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; ---