another commit without a list of specific changes! (misc)
This commit is contained in:
@@ -117,8 +117,28 @@ input:invalid {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="password"]:focus::placeholder {
|
/* Standard placeholder */
|
||||||
|
input:focus::placeholder {
|
||||||
color: transparent;
|
color: transparent;
|
||||||
|
opacity: 0; /* optional, for smoother fade in some browsers */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WebKit (Safari, Chrome, iOS) */
|
||||||
|
input:focus::-webkit-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
input:focus::-moz-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Microsoft Edge / IE */
|
||||||
|
input:focus:-ms-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove Safari input shadow on mobile */
|
/* Remove Safari input shadow on mobile */
|
||||||
@@ -213,6 +233,7 @@ Custom
|
|||||||
|
|
||||||
#lyric-search-input {
|
#lyric-search-input {
|
||||||
margin-right: 1.5%;
|
margin-right: 1.5%;
|
||||||
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#lyrics-info {
|
#lyrics-info {
|
||||||
@@ -226,9 +247,17 @@ Custom
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-card {
|
.lyrics-card {
|
||||||
border: 1px solid grey;
|
border-radius: 12px;
|
||||||
border-radius: 5px;
|
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-content {
|
||||||
line-height: 2.0;
|
line-height: 2.0;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-card-dark {
|
.lyrics-card-dark {
|
||||||
@@ -258,6 +287,17 @@ Custom
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.p-autocomplete-input {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.p-autocomplete-input:focus {
|
||||||
|
border-color: #4f46e5;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.d-dark > * {
|
.d-dark > * {
|
||||||
background-color: rgba(35, 35, 35, 0.9);
|
background-color: rgba(35, 35, 35, 0.9);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -266,12 +306,6 @@ Custom
|
|||||||
background-color: rgba(255, 255, 255, 0.9);
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-breadcrumb {
|
|
||||||
font-weight: bold;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px; /* makes it more visible */
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Toastify customizations
|
Toastify customizations
|
||||||
*/
|
*/
|
||||||
|
@@ -116,7 +116,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.station-tabs {
|
.station-tabs {
|
||||||
padding-top: 4%;
|
padding-top: 6%;
|
||||||
|
padding-bottom: 4%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-player__album {
|
.music-player__album {
|
||||||
|
@@ -32,7 +32,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 == "LoginPage" && (<LoginPage client:only="react" />)}
|
||||||
{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,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { API_URL } from "@/config";
|
import { API_URL } from "@/config";
|
||||||
|
|
||||||
@@ -7,6 +7,14 @@ export default function LoginPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const passwordRef = useRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (passwordRef.current && password === "") {
|
||||||
|
passwordRef.current.value = "";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -20,6 +28,7 @@ export default function LoginPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return toast.error("Password is required");
|
return toast.error("Password is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams();
|
||||||
formData.append("username", username);
|
formData.append("username", username);
|
||||||
formData.append("password", password);
|
formData.append("password", password);
|
||||||
@@ -30,10 +39,8 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const resp = await fetch(`${API_URL}/auth/login`, {
|
const resp = await fetch(`${API_URL}/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
credentials: "include",
|
||||||
},
|
|
||||||
credentials: "include", // Important for cookies
|
|
||||||
body: formData.toString(),
|
body: formData.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,21 +51,15 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (resp.json().detail) {
|
const data = await resp.json().catch(() => ({}));
|
||||||
toast.error(`Login failed: ${resp.json().detail}`);
|
toast.error(data.detail ? `Login failed: ${data.detail}` : "Login failed");
|
||||||
}
|
|
||||||
else {
|
|
||||||
toast.error("Login failed");
|
|
||||||
}
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (data.access_token) {
|
if (data.access_token) {
|
||||||
toast.success("Login successful!");
|
toast.success("Login successful!");
|
||||||
// Redirect
|
|
||||||
window.location.href = "/TRip"; // TODO: fix, hardcoded
|
window.location.href = "/TRip"; // TODO: fix, hardcoded
|
||||||
} else {
|
} else {
|
||||||
toast.error("Login failed: no access token received");
|
toast.error("Login failed: no access token received");
|
||||||
@@ -72,21 +73,16 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-center bg-gray-50 dark:bg-[#121212] px-4 pt-20 py-50">
|
<div className="flex items-start justify-center bg-gray-50 dark:bg-[#121212] px-4 pt-20 py-10">
|
||||||
<div className="max-w-md w-full bg-white dark:bg-[#1E1E1E] rounded-2xl shadow-xl px-10 pb-6">
|
<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">
|
<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" />
|
<img className="logo-auth mb-4" src="/images/kode.png" alt="Logo" />
|
||||||
Authentication Required
|
Authentication Required
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form className="space-y-6" onSubmit={handleSubmit} noValidate>
|
<form className="space-y-6 relative" onSubmit={handleSubmit} noValidate>
|
||||||
<div>
|
{/* Username */}
|
||||||
<label
|
<div className="relative">
|
||||||
htmlFor="username"
|
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="username"
|
id="username"
|
||||||
@@ -95,31 +91,42 @@ export default function LoginPage() {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
required
|
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}
|
disabled={loading}
|
||||||
|
className="peer block w-full px-4 pt-5 pb-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-white placeholder-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="absolute left-4 top-2 text-gray-500 dark:text-gray-400 text-sm transition-all
|
||||||
|
peer-placeholder-shown:top-5 peer-placeholder-shown:text-gray-400 peer-placeholder-shown:text-base
|
||||||
|
peer-focus:top-2 peer-focus:text-sm peer-focus:text-blue-500 dark:peer-focus:text-blue-400"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Password */}
|
||||||
<label
|
<div className="relative">
|
||||||
htmlFor="password"
|
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
autoComplete="off"
|
autoComplete="new-password"
|
||||||
|
spellCheck="false"
|
||||||
|
ref={passwordRef}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
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}
|
disabled={loading}
|
||||||
|
className="peer block w-full px-4 pt-5 pb-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-white placeholder-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="absolute left-4 top-2 text-gray-500 dark:text-gray-400 text-sm transition-all
|
||||||
|
peer-placeholder-shown:top-5 peer-placeholder-shown:text-gray-400 peer-placeholder-shown:text-base
|
||||||
|
peer-focus:top-2 peer-focus:text-sm peer-focus:text-blue-500 dark:peer-focus:text-blue-400"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
@@ -23,7 +23,7 @@ export default function LyricSearch() {
|
|||||||
<span>Lyric Search</span>
|
<span>Lyric Search</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="card-text my-4">
|
<div className="card-text my-4">
|
||||||
<label for="lyric-search-input">Search:</label>
|
<label htmlFor="lyric-search-input">Search:</label>
|
||||||
<LyricSearchInputField
|
<LyricSearchInputField
|
||||||
id="lyric-search-input"
|
id="lyric-search-input"
|
||||||
placeholder="Artist - Song"
|
placeholder="Artist - Song"
|
||||||
|
@@ -27,9 +27,6 @@ export default function BreadcrumbNav({ currentPage }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav >
|
</nav >
|
||||||
<div className="mb-2">
|
|
||||||
Self Service
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -3,6 +3,7 @@ import { toast } from "react-toastify";
|
|||||||
import { Button } from "@mui/joy";
|
import { Button } from "@mui/joy";
|
||||||
import { Accordion, AccordionTab } from "primereact/accordion";
|
import { Accordion, AccordionTab } from "primereact/accordion";
|
||||||
import { AutoComplete } from "primereact/autocomplete";
|
import { AutoComplete } from "primereact/autocomplete";
|
||||||
|
import { authFetch } from "@/utils/authFetch";
|
||||||
import BreadcrumbNav from "./BreadcrumbNav";
|
import BreadcrumbNav from "./BreadcrumbNav";
|
||||||
import { API_URL, ENVIRONMENT } from "@/config";
|
import { API_URL, ENVIRONMENT } from "@/config";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export default function MediaRequestForm() {
|
|||||||
const [artistInput, setArtistInput] = useState("");
|
const [artistInput, setArtistInput] = useState("");
|
||||||
const [albumInput, setAlbumInput] = useState("");
|
const [albumInput, setAlbumInput] = useState("");
|
||||||
const [trackInput, setTrackInput] = useState("");
|
const [trackInput, setTrackInput] = useState("");
|
||||||
|
const [quality, setQuality] = useState("FLAC"); // default FLAC
|
||||||
const [selectedItem, setSelectedItem] = useState(null);
|
const [selectedItem, setSelectedItem] = useState(null);
|
||||||
const [albums, setAlbums] = useState([]);
|
const [albums, setAlbums] = useState([]);
|
||||||
const [tracksByAlbum, setTracksByAlbum] = useState({});
|
const [tracksByAlbum, setTracksByAlbum] = useState({});
|
||||||
@@ -29,15 +31,6 @@ export default function MediaRequestForm() {
|
|||||||
|
|
||||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
|
||||||
|
|
||||||
// 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const Spinner = () => (
|
const Spinner = () => (
|
||||||
<span
|
<span
|
||||||
@@ -82,8 +75,6 @@ export default function MediaRequestForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//helpers for string truncation
|
|
||||||
const truncate = (text, maxLen) =>
|
const truncate = (text, maxLen) =>
|
||||||
maxLen <= 3
|
maxLen <= 3
|
||||||
? text.slice(0, maxLen)
|
? text.slice(0, maxLen)
|
||||||
@@ -138,7 +129,7 @@ export default function MediaRequestForm() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(
|
const res = await authFetch(
|
||||||
`${API_URL}/trip/get_albums_by_artist_id/${selectedArtist.id}`
|
`${API_URL}/trip/get_albums_by_artist_id/${selectedArtist.id}?quality=${quality}`
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error("API error");
|
if (!res.ok) throw new Error("API error");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -190,13 +181,21 @@ export default function MediaRequestForm() {
|
|||||||
|
|
||||||
const handleTrackClick = async (trackId, artist, title) => {
|
const handleTrackClick = async (trackId, artist, title) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`${API_URL}/trip/get_track_by_id/${trackId}`);
|
const res = await authFetch(`${API_URL}/trip/get_track_by_id/${trackId}?quality=${quality}`);
|
||||||
if (!res.ok) throw new Error("Failed to fetch track URL");
|
if (!res.ok) throw new Error("Failed to fetch track URL");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.stream_url) {
|
if (data.stream_url) {
|
||||||
const fileResponse = await authFetch(data.stream_url);
|
// Use plain fetch for public resource
|
||||||
if (!fileResponse.ok) throw new Error("Failed to fetch track file");
|
const fileResponse = await fetch(data.stream_url, {
|
||||||
|
method: "GET",
|
||||||
|
mode: "cors", // ensure cross-origin is allowed
|
||||||
|
credentials: "omit", // do NOT send cookies or auth
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fileResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch track file: ${fileResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
const blob = await fileResponse.blob();
|
const blob = await fileResponse.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -204,16 +203,14 @@ export default function MediaRequestForm() {
|
|||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|
||||||
// Sanitize filename (remove / or other illegal chars)
|
|
||||||
const sanitize = (str) => str.replace(/[\\/:*?"<>|]/g, "_");
|
const sanitize = (str) => str.replace(/[\\/:*?"<>|]/g, "_");
|
||||||
const filename = `${sanitize(artist)} - ${sanitize(title)}.flac`;
|
const filename = `${sanitize(artist)} - ${sanitize(title)}.flac`;
|
||||||
|
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
|
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
link.remove();
|
link.remove();
|
||||||
|
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} else {
|
} else {
|
||||||
toast.error("No stream URL returned for this track.");
|
toast.error("No stream URL returned for this track.");
|
||||||
@@ -271,7 +268,7 @@ export default function MediaRequestForm() {
|
|||||||
if (albumsToFetch.length === 0) return;
|
if (albumsToFetch.length === 0) return;
|
||||||
|
|
||||||
const fetchTracksSequentially = async () => {
|
const fetchTracksSequentially = async () => {
|
||||||
const minDelay = 200; // ms between API requests
|
const minDelay = 500; // ms between API requests
|
||||||
setIsFetching(true);
|
setIsFetching(true);
|
||||||
|
|
||||||
const totalAlbums = albumsToFetch.length;
|
const totalAlbums = albumsToFetch.length;
|
||||||
@@ -402,7 +399,8 @@ export default function MediaRequestForm() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
track_ids: allSelectedIds,
|
track_ids: allSelectedIds,
|
||||||
target: selectedArtist.artist
|
target: selectedArtist.artist,
|
||||||
|
quality: quality,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -494,10 +492,10 @@ export default function MediaRequestForm() {
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<BreadcrumbNav currentPage="request" />
|
<BreadcrumbNav currentPage="request" />
|
||||||
|
<h2 className="text-3xl font-semibold mt-0">New Request</h2>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<label for="artistInput">Artist: </label>
|
<label htmlFor="artistInput">Artist: </label>
|
||||||
<AutoComplete
|
<AutoComplete
|
||||||
id={artistInput}
|
id={artistInput}
|
||||||
ref={autoCompleteRef}
|
ref={autoCompleteRef}
|
||||||
@@ -526,6 +524,20 @@ export default function MediaRequestForm() {
|
|||||||
placeholder={type === "album" ? "Album" : "Track"}
|
placeholder={type === "album" ? "Album" : "Track"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center gap-4 justify-end mt-2">
|
||||||
|
<label htmlFor="qualitySelect" className="text-sm font-medium">
|
||||||
|
Quality:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="qualitySelect"
|
||||||
|
value={quality}
|
||||||
|
onChange={(e) => setQuality(e.target.value)}
|
||||||
|
className="border border-neutral-300 dark:border-neutral-600 rounded px-2 py-1 bg-white dark:bg-neutral-800 text-black dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="FLAC">FLAC</option>
|
||||||
|
<option value="Lossy">Lossy</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleSearch} disabled={isSearching}>
|
<Button onClick={handleSearch} disabled={isSearching}>
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
@@ -616,7 +628,7 @@ export default function MediaRequestForm() {
|
|||||||
>
|
>
|
||||||
{truncate(track.title, 80)}
|
{truncate(track.title, 80)}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-neutral-500">{track.audioQuality}</span>
|
<span className="text-xs text-neutral-500">{quality}</span>
|
||||||
{track.version && (
|
{track.version && (
|
||||||
<span className="text-xs text-neutral-400">({track.version})</span>
|
<span className="text-xs text-neutral-400">({track.version})</span>
|
||||||
)}
|
)}
|
||||||
@@ -636,7 +648,6 @@ export default function MediaRequestForm() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleSubmitRequest} color="primary" className="mt-4" disabled={isSubmitting}>
|
<Button onClick={handleSubmitRequest} color="primary" className="mt-4" disabled={isSubmitting}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
|
@@ -5,12 +5,13 @@ 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 { Dialog } from "primereact/dialog";
|
import { Dialog } from "primereact/dialog";
|
||||||
|
import { authFetch } from "@/utils/authFetch";
|
||||||
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
|
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
|
||||||
import BreadcrumbNav from "./BreadcrumbNav";
|
import BreadcrumbNav from "./BreadcrumbNav";
|
||||||
import { API_URL } from "@/config";
|
import { API_URL } from "@/config";
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["queued", "started", "compressing", "finished", "failed"];
|
const STATUS_OPTIONS = ["queued", "started", "compressing", "finished", "failed"];
|
||||||
const TAR_BASE_URL = "https://codey.lol/m/m2/"; // configurable prefix
|
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
|
||||||
|
|
||||||
export default function RequestManagement() {
|
export default function RequestManagement() {
|
||||||
const [requests, setRequests] = useState([]);
|
const [requests, setRequests] = useState([]);
|
||||||
@@ -22,12 +23,11 @@ export default function RequestManagement() {
|
|||||||
const pollingRef = useRef(null);
|
const pollingRef = useRef(null);
|
||||||
const pollingDetailRef = useRef(null);
|
const pollingDetailRef = useRef(null);
|
||||||
|
|
||||||
const authFetch = async (url, options = {}) => fetch(url, { ...options, credentials: "include" });
|
|
||||||
|
|
||||||
const tarballUrl = (absPath) => {
|
const tarballUrl = (absPath, quality) => {
|
||||||
if (!absPath) return null;
|
if (!absPath) return null;
|
||||||
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
|
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
|
||||||
return `${TAR_BASE_URL}${filename}`;
|
return `${TAR_BASE_URL}/${quality}/${filename}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchJobs = async () => {
|
const fetchJobs = async () => {
|
||||||
@@ -53,7 +53,12 @@ export default function RequestManagement() {
|
|||||||
return await res.json();
|
return await res.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast.error("Failed to fetch job details");
|
if (!toast.isActive('fetch-job-fail-toast')) {
|
||||||
|
toast.error("Failed to fetch job details",
|
||||||
|
{
|
||||||
|
toastId: "fetch-job-fail-toast",
|
||||||
|
});
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -108,17 +113,41 @@ export default function RequestManagement() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getQualityColorClass = (quality) => {
|
||||||
|
switch (quality) {
|
||||||
|
case "FLAC": return "bg-green-500 text-white";
|
||||||
|
case "Lossy": return "bg-yellow-500 text-white";
|
||||||
|
default: return "bg-gray-500 text-white";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const statusBodyTemplate = (rowData) => (
|
const statusBodyTemplate = (rowData) => (
|
||||||
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getStatusColorClass(rowData.status)}`}>
|
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getStatusColorClass(rowData.status)}`}>
|
||||||
{rowData.status}
|
{rowData.status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const qualityBodyTemplate = (rowData) => (
|
||||||
|
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getQualityColorClass(rowData.quality)}`}>
|
||||||
|
{rowData.quality}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
const safeText = (val) => (val === 0 ? "0" : val || "—");
|
const safeText = (val) => (val === 0 ? "0" : val || "—");
|
||||||
const textWithEllipsis = (val, width = "12rem") => (
|
const textWithEllipsis = (val, width = "12rem") => (
|
||||||
<span className="truncate block" style={{ maxWidth: width }} title={val || ""}>{val || "—"}</span>
|
<span className="truncate block" style={{ maxWidth: width }} title={val || ""}>{val || "—"}</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const truncate = (text, maxLen) =>
|
||||||
|
maxLen <= 3
|
||||||
|
? text.slice(0, maxLen)
|
||||||
|
: text.length <= maxLen
|
||||||
|
? text
|
||||||
|
: text.slice(0, maxLen - 3) + '...';
|
||||||
|
|
||||||
|
|
||||||
const basename = (p) => (typeof p === "string" ? p.split("/").pop() : "");
|
const basename = (p) => (typeof p === "string" ? p.split("/").pop() : "");
|
||||||
|
|
||||||
const formatProgress = (p) => {
|
const formatProgress = (p) => {
|
||||||
@@ -280,16 +309,16 @@ export default function RequestManagement() {
|
|||||||
responsiveLayout="scroll"
|
responsiveLayout="scroll"
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
>
|
>
|
||||||
<Column field="id" header="ID" sortable style={{ width: "10rem" }} body={(row) => textWithEllipsis(row.id, "8rem")} />
|
<Column field="id" header="ID" sortable style={{ width: "8rem" }} body={(row) => textWithEllipsis(row.id, "6rem")} />
|
||||||
<Column field="target" header="Target" sortable style={{ width: "12rem" }} body={(row) => textWithEllipsis(row.target, "10rem")} />
|
<Column field="target" header="Target" sortable style={{ width: "12rem" }} body={(row) => textWithEllipsis(row.target, "10rem")} />
|
||||||
<Column field="tracks" header="# Tracks" sortable style={{ width: "10rem" }} body={(row) => row.tracks} />
|
<Column field="tracks" header="# Tracks" sortable style={{ width: "8rem" }} body={(row) => row.tracks} />
|
||||||
<Column field="status" header="Status" body={statusBodyTemplate} style={{ width: "10rem", textAlign: "center" }} sortable />
|
<Column field="status" header="Status" body={statusBodyTemplate} style={{ width: "10rem", textAlign: "center" }} sortable />
|
||||||
<Column field="progress" header="Progress" body={(row) => formatProgress(row.progress)} style={{ width: "8rem", textAlign: "center" }} sortable />
|
<Column field="progress" header="Progress" body={(row) => formatProgress(row.progress)} style={{ width: "8rem", textAlign: "center" }} sortable />
|
||||||
<Column
|
<Column
|
||||||
field="tarball"
|
field="tarball"
|
||||||
header="Tarball"
|
header="Tarball"
|
||||||
body={(row) => {
|
body={(row) => {
|
||||||
const url = tarballUrl(row.tarball);
|
const url = tarballUrl(row.tarball, row.quality || "FLAC");
|
||||||
return url ? (
|
return url ? (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
@@ -298,14 +327,20 @@ export default function RequestManagement() {
|
|||||||
className="text-blue-500 hover:underline truncate block"
|
className="text-blue-500 hover:underline truncate block"
|
||||||
title={url.split("/").pop()}
|
title={url.split("/").pop()}
|
||||||
>
|
>
|
||||||
{url.split("/").pop()}
|
{truncate(url.split("/").pop(), 16)}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
style={{ width: "18rem" }}
|
style={{ width: "10rem" }}
|
||||||
/>
|
/>
|
||||||
|
<Column
|
||||||
|
field="quality"
|
||||||
|
header="Quality"
|
||||||
|
body={qualityBodyTemplate}
|
||||||
|
style={{ width: "6rem", textAlign: "center" }}
|
||||||
|
sortable />
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -368,10 +403,17 @@ export default function RequestManagement() {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{selectedRequest.quality && (
|
||||||
|
<p><strong>Quality:</strong>{" "}
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-full text-xs font-bold ${getQualityColorClass(selectedRequest.quality)}`}
|
||||||
|
>{selectedRequest.quality}</span></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
</Dialog >
|
</Dialog >
|
||||||
|
|
||||||
</div >
|
</div >
|
||||||
|
58
src/hooks/requireAuthHook.js
Normal file
58
src/hooks/requireAuthHook.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export const requireAuthHook = async () => {
|
||||||
|
const token = Astro.cookies.get("access_token")?.value;
|
||||||
|
let user = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!token) throw Error("No access token");
|
||||||
|
|
||||||
|
// Step 1: verify current access token
|
||||||
|
user = verifyToken(token);
|
||||||
|
|
||||||
|
if (!user) throw Error("Invalid access token");
|
||||||
|
|
||||||
|
console.log("Verified!", user);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Access token check failed:", err.message);
|
||||||
|
|
||||||
|
// Step 2: attempt refresh if refresh_token exists
|
||||||
|
const refreshToken = Astro.cookies.get("refresh_token")?.value;
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const newTokens = await refreshAccessToken(refreshToken);
|
||||||
|
if (newTokens?.accessToken) {
|
||||||
|
// store new access token
|
||||||
|
Astro.cookies.set("access_token", newTokens.accessToken, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optionally replace refresh_token too
|
||||||
|
if (newTokens.refreshToken) {
|
||||||
|
Astro.cookies.set("refresh_token", newTokens.refreshToken, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// re-verify user with new token
|
||||||
|
user = verifyToken(newTokens.accessToken);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
console.log("Refreshed + verified!", user);
|
||||||
|
return; // ✅ authenticated now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (refreshErr) {
|
||||||
|
console.error("Refresh failed:", refreshErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: if still no user, redirect
|
||||||
|
return Astro.redirect("/login");
|
||||||
|
}
|
||||||
|
}
|
@@ -40,12 +40,15 @@ const currentPath = Astro.url.pathname;
|
|||||||
|
|
||||||
const isExternal = item.href?.startsWith("http");
|
const isExternal = item.href?.startsWith("http");
|
||||||
const isAuthedPath = item.auth ?? false;
|
const isAuthedPath = item.auth ?? false;
|
||||||
const normalize = (url) => url?.replace(/\/+$/, '') || '/';
|
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
||||||
const normalizedCurrent = normalize(currentPath).replace(/\/$/, ""); // remove trailing slash
|
const normalizedCurrent = normalize(currentPath);
|
||||||
const normalizedHref = normalize(item.href).replace(/\/$/, "");
|
const normalizedHref = normalize(item.href);
|
||||||
const isActive = !isExternal && (
|
const isActive = !isExternal && (
|
||||||
normalizedCurrent === normalizedHref ||
|
normalizedHref === '/'
|
||||||
normalizedCurrent.startsWith(normalizedHref + "/"));
|
? normalizedCurrent === '/' // Home only matches exact /
|
||||||
|
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
const nextItem = navItems[index + 1];
|
const nextItem = navItems[index + 1];
|
||||||
const shouldShowThinBar = nextItem //&& !nextItem.blockSeparator;
|
const shouldShowThinBar = nextItem //&& !nextItem.blockSeparator;
|
||||||
|
@@ -3,7 +3,9 @@ import MediaRequestForm from "@/components/TRip/MediaRequestForm"
|
|||||||
import Base from "@/layouts/Base.astro";
|
import Base from "@/layouts/Base.astro";
|
||||||
import Root from "@/components/AppLayout.jsx";
|
import Root from "@/components/AppLayout.jsx";
|
||||||
import { verifyToken } from "@/utils/jwt";
|
import { verifyToken } from "@/utils/jwt";
|
||||||
|
import { refreshAccessToken } from "@/utils/authFetch";
|
||||||
import { ENVIRONMENT } from "@/config";
|
import { ENVIRONMENT } from "@/config";
|
||||||
|
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||||
|
|
||||||
const token = Astro.cookies.get("access_token")?.value;
|
const token = Astro.cookies.get("access_token")?.value;
|
||||||
let user = null;
|
let user = null;
|
||||||
|
@@ -3,26 +3,9 @@ import MediaRequestForm from "@/components/TRip/MediaRequestForm"
|
|||||||
import Base from "@/layouts/Base.astro";
|
import Base from "@/layouts/Base.astro";
|
||||||
import Root from "@/components/AppLayout.jsx";
|
import Root from "@/components/AppLayout.jsx";
|
||||||
import { verifyToken } from "@/utils/jwt";
|
import { verifyToken } from "@/utils/jwt";
|
||||||
|
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||||
import { ENVIRONMENT } from "@/config";
|
import { ENVIRONMENT } from "@/config";
|
||||||
|
|
||||||
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>
|
<Base>
|
||||||
<section>
|
<section>
|
||||||
|
@@ -9,7 +9,5 @@ import "@styles/player.css";
|
|||||||
<div class="prose prose-neutral dark:prose-invert">
|
<div class="prose prose-neutral dark:prose-invert">
|
||||||
<Root child="Player" client:only="react">
|
<Root child="Player" client:only="react">
|
||||||
</Root>
|
</Root>
|
||||||
<script is:inline src="/scripts/jquery/dist/jquery.js" />
|
|
||||||
<script is:inline src="/scripts/howler/dist/howler.js" />
|
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
51
src/utils/authFetch.js
Normal file
51
src/utils/authFetch.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { API_URL } from "@/config";
|
||||||
|
|
||||||
|
|
||||||
|
// Auth fetch wrapper
|
||||||
|
export const authFetch = async (url, options = {}, retry = true) => {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
credentials: "include", // cookie goes automatically
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401 && retry) {
|
||||||
|
// attempt refresh
|
||||||
|
try {
|
||||||
|
const refreshRes = await fetch(`${API_URL}/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!refreshRes.ok) throw new Error("Refresh failed");
|
||||||
|
|
||||||
|
// Retry original request once after refresh
|
||||||
|
return authFetch(url, options, false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Refresh token failed:", err);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh token function (HttpOnly cookie flow)
|
||||||
|
export async function refreshAccessToken() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include", // send HttpOnly cookies
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typically the server just updates the cookie
|
||||||
|
// It may return a new access token too, but we don’t store it client-side.
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Refresh token failed:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user