2025-06-18 07:46:59 -04:00
|
|
|
import { CircularProgress } from "@mui/joy";
|
|
|
|
|
import React, {
|
|
|
|
|
forwardRef,
|
|
|
|
|
useImperativeHandle,
|
|
|
|
|
useEffect,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
2025-09-24 16:30:13 -04:00
|
|
|
useCallback,
|
2025-06-18 07:46:59 -04:00
|
|
|
} from "react";
|
2025-08-09 07:10:04 -04:00
|
|
|
import { toast } from 'react-toastify';
|
2025-06-18 07:46:59 -04:00
|
|
|
import Box from '@mui/joy/Box';
|
|
|
|
|
import Button from "@mui/joy/Button";
|
2025-11-25 10:04:05 -05:00
|
|
|
import IconButton from "@mui/joy/IconButton";
|
2025-06-18 07:46:59 -04:00
|
|
|
import Checkbox from "@mui/joy/Checkbox";
|
2025-07-15 14:34:44 -04:00
|
|
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
2025-11-25 10:04:05 -05:00
|
|
|
import LinkIcon from '@mui/icons-material/Link';
|
|
|
|
|
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
|
|
|
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
|
|
|
|
import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
|
2025-06-18 07:46:59 -04:00
|
|
|
import { AutoComplete } from 'primereact/autocomplete';
|
2025-07-25 10:06:39 -04:00
|
|
|
import { API_URL } from '../config';
|
2025-06-18 07:46:59 -04:00
|
|
|
|
|
|
|
|
export default function LyricSearch() {
|
2025-07-31 19:53:45 -04:00
|
|
|
const [showLyrics, setShowLyrics] = useState(false);
|
2025-10-08 15:49:00 -04:00
|
|
|
|
2025-06-18 07:46:59 -04:00
|
|
|
return (
|
|
|
|
|
<div className="lyric-search">
|
2025-07-31 19:53:45 -04:00
|
|
|
<h2 className="title">
|
|
|
|
|
<span>Lyric Search</span>
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="card-text my-4">
|
2025-08-21 15:07:10 -04:00
|
|
|
<label htmlFor="lyric-search-input">Search:</label>
|
2025-07-31 19:53:45 -04:00
|
|
|
<LyricSearchInputField
|
|
|
|
|
id="lyric-search-input"
|
|
|
|
|
placeholder="Artist - Song"
|
2025-11-25 10:04:05 -05:00
|
|
|
setShowLyrics={setShowLyrics}
|
|
|
|
|
/>
|
2025-07-31 19:53:45 -04:00
|
|
|
<div id="spinner" className="hidden">
|
2025-11-25 10:04:05 -05:00
|
|
|
<CircularProgress variant="plain" color="primary" size="md" />
|
|
|
|
|
</div>
|
2025-07-31 19:53:45 -04:00
|
|
|
</div>
|
2025-06-18 07:46:59 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
2025-06-18 07:46:59 -04:00
|
|
|
const [value, setValue] = useState("");
|
|
|
|
|
const [suggestions, setSuggestions] = useState([]);
|
2025-07-16 10:06:41 -04:00
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
const [excludedSources, setExcludedSources] = useState([]);
|
|
|
|
|
const [lyricsResult, setLyricsResult] = useState(null);
|
2025-11-25 10:04:05 -05:00
|
|
|
const [textSize, setTextSize] = useState("normal");
|
|
|
|
|
const [inputStatus, setInputStatus] = useState("hint");
|
2025-09-22 11:15:24 -04:00
|
|
|
const searchToastRef = useRef(null);
|
2025-06-18 07:46:59 -04:00
|
|
|
const autoCompleteRef = useRef(null);
|
2025-11-25 13:05:37 -05:00
|
|
|
const autoCompleteInputRef = useRef(null);
|
|
|
|
|
const searchButtonRef = useRef(null);
|
2025-07-31 19:53:45 -04:00
|
|
|
const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light");
|
2025-11-25 10:04:05 -05:00
|
|
|
const statusLabels = {
|
|
|
|
|
hint: "Format: Artist - Song",
|
|
|
|
|
error: "Artist and song required",
|
|
|
|
|
ready: "Format looks good",
|
|
|
|
|
};
|
|
|
|
|
const statusIcons = {
|
|
|
|
|
hint: RemoveRoundedIcon,
|
|
|
|
|
error: CloseRoundedIcon,
|
|
|
|
|
ready: CheckCircleRoundedIcon,
|
|
|
|
|
};
|
|
|
|
|
const statusColors = {
|
|
|
|
|
hint: "#9ca3af",
|
|
|
|
|
error: "#ef4444",
|
|
|
|
|
ready: "#22c55e",
|
|
|
|
|
};
|
2025-07-31 19:53:45 -04:00
|
|
|
|
2025-10-08 15:49:00 -04:00
|
|
|
// Handle URL hash changes and initial load
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleHashChange = () => {
|
|
|
|
|
const hash = window.location.hash.slice(1); // Remove the # symbol
|
|
|
|
|
if (hash) {
|
|
|
|
|
try {
|
|
|
|
|
const [artist, song] = decodeURIComponent(hash).split('/');
|
|
|
|
|
if (artist && song) {
|
|
|
|
|
setValue(`${artist} - ${song}`);
|
|
|
|
|
handleSearch(`${artist} - ${song}`);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to parse URL hash:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('hashchange', handleHashChange);
|
|
|
|
|
handleHashChange(); // Handle initial load
|
|
|
|
|
|
|
|
|
|
return () => window.removeEventListener('hashchange', handleHashChange);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-07-31 19:53:45 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = (e) => {
|
|
|
|
|
const newTheme = e.detail;
|
|
|
|
|
setTheme(newTheme);
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener('set-theme', handler);
|
|
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener('set-theme', handler);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
// Typeahead: fetch suggestions
|
2025-09-24 16:30:13 -04:00
|
|
|
const fetchSuggestions = useCallback(async (event) => {
|
2025-07-16 10:06:41 -04:00
|
|
|
const query = event.query;
|
|
|
|
|
const res = await fetch(`${API_URL}/typeahead/lyrics`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ query }),
|
|
|
|
|
});
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
setSuggestions(json);
|
2025-09-24 16:30:13 -04:00
|
|
|
}, []);
|
2025-07-16 10:06:41 -04:00
|
|
|
|
|
|
|
|
// Toggle exclusion state for checkboxes
|
|
|
|
|
const toggleExclusion = (source) => {
|
2025-07-19 08:01:29 -04:00
|
|
|
const lower = source.toLowerCase();
|
2025-07-16 10:06:41 -04:00
|
|
|
setExcludedSources((prev) =>
|
2025-07-19 08:01:29 -04:00
|
|
|
prev.includes(lower)
|
|
|
|
|
? prev.filter((s) => s !== lower)
|
|
|
|
|
: [...prev, lower]
|
2025-07-16 10:06:41 -04:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-07-19 08:01:29 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
// Show scrollable dropdown panel with mouse wheel handling
|
2025-07-15 14:34:44 -04:00
|
|
|
const handlePanelShow = () => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const panel = document.querySelector(".p-autocomplete-panel");
|
|
|
|
|
const items = panel?.querySelector(".p-autocomplete-items");
|
|
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
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 });
|
|
|
|
|
}
|
2025-07-15 14:34:44 -04:00
|
|
|
}, 0);
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-25 10:04:05 -05:00
|
|
|
const evaluateSearchValue = useCallback((searchValue, shouldUpdate = true) => {
|
|
|
|
|
const trimmed = searchValue?.trim() || "";
|
|
|
|
|
let status = "hint";
|
|
|
|
|
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
if (shouldUpdate) setInputStatus(status);
|
|
|
|
|
return { status, valid: false };
|
2025-06-18 07:46:59 -04:00
|
|
|
}
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-11-25 10:04:05 -05:00
|
|
|
if (!trimmed.includes(" - ")) {
|
|
|
|
|
status = "error";
|
|
|
|
|
if (shouldUpdate) setInputStatus(status);
|
|
|
|
|
return { status, valid: false };
|
2025-06-18 07:46:59 -04:00
|
|
|
}
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-11-25 10:04:05 -05:00
|
|
|
const [artist, song] = trimmed.split(" - ", 2).map((v) => v.trim());
|
2025-07-16 10:06:41 -04:00
|
|
|
if (!artist || !song) {
|
2025-11-25 10:04:05 -05:00
|
|
|
status = "error";
|
|
|
|
|
if (shouldUpdate) setInputStatus(status);
|
|
|
|
|
return { status, valid: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
status = "ready";
|
|
|
|
|
if (shouldUpdate) setInputStatus(status);
|
|
|
|
|
return { status, valid: true, artist, song };
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
evaluateSearchValue(value);
|
|
|
|
|
}, [value, evaluateSearchValue]);
|
|
|
|
|
|
|
|
|
|
const handleSearch = async (searchValue = value) => {
|
|
|
|
|
if (autoCompleteRef.current) {
|
|
|
|
|
autoCompleteRef.current.hide();
|
|
|
|
|
}
|
2025-11-25 13:05:37 -05:00
|
|
|
autoCompleteInputRef.current?.blur(); // blur early so the suggestion panel closes immediately
|
2025-11-25 10:04:05 -05:00
|
|
|
|
|
|
|
|
const evaluation = evaluateSearchValue(searchValue);
|
|
|
|
|
if (!evaluation?.valid) {
|
|
|
|
|
const message = statusLabels[evaluation?.status || inputStatus] || "Please use Artist - Song";
|
|
|
|
|
toast.error(message);
|
2025-07-15 14:34:44 -04:00
|
|
|
return;
|
2025-06-18 07:46:59 -04:00
|
|
|
}
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-11-25 10:04:05 -05:00
|
|
|
const { artist, song } = evaluation;
|
2025-07-16 10:06:41 -04:00
|
|
|
setIsLoading(true);
|
|
|
|
|
setLyricsResult(null);
|
|
|
|
|
setShowLyrics(false);
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-11-25 13:05:37 -05:00
|
|
|
const toastId = "lyrics-searching-toast";
|
|
|
|
|
toast.dismiss(toastId);
|
|
|
|
|
searchToastRef.current = toast.info("Searching...", {
|
|
|
|
|
toastId,
|
|
|
|
|
});
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
const startTime = Date.now();
|
2025-11-25 13:05:37 -05:00
|
|
|
const dismissSearchToast = () => {
|
|
|
|
|
if (searchToastRef.current) {
|
|
|
|
|
toast.dismiss(searchToastRef.current);
|
|
|
|
|
searchToastRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-07-16 10:06:41 -04:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${API_URL}/lyric/search`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
a: artist,
|
|
|
|
|
s: song,
|
|
|
|
|
excluded_sources: excludedSources,
|
|
|
|
|
src: "Web",
|
|
|
|
|
extra: true,
|
|
|
|
|
}),
|
|
|
|
|
});
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-11-25 13:05:37 -05:00
|
|
|
if (res.status === 429) {
|
|
|
|
|
throw new Error("Too many requests. Please wait a moment and try again.");
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
const data = await res.json();
|
|
|
|
|
if (!res.ok || !data.lyrics) {
|
|
|
|
|
throw new Error(data.errorText || "Unknown error.");
|
2025-06-18 07:46:59 -04:00
|
|
|
}
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-16 10:06:41 -04:00
|
|
|
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
2025-11-25 10:04:05 -05:00
|
|
|
setTextSize("normal");
|
2025-07-16 10:06:41 -04:00
|
|
|
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
|
|
|
|
|
setShowLyrics(true);
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-10-08 15:49:00 -04:00
|
|
|
// Update URL hash with search parameters
|
|
|
|
|
const hash = `#${encodeURIComponent(data.artist)}/${encodeURIComponent(data.song)}`;
|
|
|
|
|
window.history.pushState(null, '', hash);
|
|
|
|
|
|
2025-11-25 13:05:37 -05:00
|
|
|
dismissSearchToast();
|
|
|
|
|
toast.success(`Found! (Took ${duration}s)`, {
|
2025-07-31 20:36:34 -04:00
|
|
|
autoClose: 2500,
|
2025-11-25 13:05:37 -05:00
|
|
|
toastId: `lyrics-success-${Date.now()}`,
|
2025-06-18 07:46:59 -04:00
|
|
|
});
|
2025-07-16 10:06:41 -04:00
|
|
|
} catch (error) {
|
2025-11-25 13:05:37 -05:00
|
|
|
dismissSearchToast();
|
|
|
|
|
toast.error(error.message, {
|
2025-11-22 21:41:41 -05:00
|
|
|
icon: () => "😕",
|
2025-06-18 07:46:59 -04:00
|
|
|
autoClose: 5000,
|
2025-07-15 14:34:44 -04:00
|
|
|
});
|
2025-07-16 10:06:41 -04:00
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
2025-11-25 13:05:37 -05:00
|
|
|
autoCompleteInputRef.current?.blur();
|
|
|
|
|
searchButtonRef.current?.blur();
|
|
|
|
|
searchToastRef.current = null;
|
2025-07-16 10:06:41 -04:00
|
|
|
}
|
2025-06-18 07:46:59 -04:00
|
|
|
};
|
2025-07-15 14:34:44 -04:00
|
|
|
|
|
|
|
|
const handleKeyDown = (e) => {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
handleSearch();
|
|
|
|
|
}
|
2025-06-18 07:46:59 -04:00
|
|
|
};
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-11-25 10:04:05 -05:00
|
|
|
const handleCopyLyrics = useCallback(async () => {
|
|
|
|
|
if (!lyricsResult?.lyrics) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const temp = document.createElement("div");
|
|
|
|
|
temp.innerHTML = lyricsResult.lyrics
|
|
|
|
|
.replace(/<br\s*\/?>(\s*<br\s*\/?>)?/gi, (_match, doubleBreak) => {
|
|
|
|
|
return doubleBreak ? "\n\n" : "\n";
|
|
|
|
|
})
|
|
|
|
|
.replace(/<p\b[^>]*>/gi, "")
|
|
|
|
|
.replace(/<\/p>/gi, "\n\n");
|
|
|
|
|
const plainText = temp.textContent || temp.innerText || "";
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(plainText);
|
|
|
|
|
toast.success("Lyrics copied to clipboard", { autoClose: 1500 });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error("Unable to copy lyrics");
|
|
|
|
|
console.error("Copy failed", err);
|
|
|
|
|
}
|
|
|
|
|
}, [lyricsResult]);
|
|
|
|
|
|
|
|
|
|
const handleCopyLink = useCallback(async () => {
|
|
|
|
|
if (!lyricsResult) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const url = new URL(window.location.href);
|
|
|
|
|
const hash = `#${encodeURIComponent(lyricsResult.artist)}/${encodeURIComponent(lyricsResult.song)}`;
|
|
|
|
|
url.hash = hash;
|
|
|
|
|
await navigator.clipboard.writeText(url.toString());
|
|
|
|
|
toast.success("Lyric link copied", { autoClose: 1500 });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error("Unable to copy link");
|
|
|
|
|
console.error("Link copy failed", err);
|
|
|
|
|
}
|
|
|
|
|
}, [lyricsResult]);
|
|
|
|
|
|
|
|
|
|
const statusTitle = statusLabels[inputStatus];
|
|
|
|
|
const StatusIcon = statusIcons[inputStatus] || RemoveRoundedIcon;
|
|
|
|
|
|
2025-11-26 09:17:30 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
const inputEl = autoCompleteInputRef.current;
|
|
|
|
|
if (!inputEl) return;
|
|
|
|
|
if (statusTitle) {
|
|
|
|
|
inputEl.setAttribute("title", statusTitle);
|
|
|
|
|
} else {
|
|
|
|
|
inputEl.removeAttribute("title");
|
|
|
|
|
}
|
|
|
|
|
}, [statusTitle]);
|
|
|
|
|
|
2025-06-18 07:46:59 -04:00
|
|
|
return (
|
|
|
|
|
<div>
|
2025-11-25 10:04:05 -05:00
|
|
|
<div className="lyric-search-input-wrapper">
|
|
|
|
|
<AutoComplete
|
|
|
|
|
id={id}
|
|
|
|
|
ref={autoCompleteRef}
|
|
|
|
|
value={value}
|
|
|
|
|
suggestions={suggestions}
|
|
|
|
|
completeMethod={fetchSuggestions}
|
|
|
|
|
onChange={(e) => setValue(e.target.value)}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
onShow={handlePanelShow}
|
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
autoFocus
|
|
|
|
|
style={{ width: '100%', maxWidth: '900px' }}
|
|
|
|
|
inputStyle={{ width: '100%' }}
|
|
|
|
|
className={`lyric-search-input ${inputStatus === "error" ? "has-error" : ""} ${inputStatus === "ready" ? "has-ready" : ""}`}
|
|
|
|
|
aria-invalid={inputStatus === "error"}
|
|
|
|
|
aria-label={`Lyric search input. ${statusTitle}`}
|
2025-11-25 13:05:37 -05:00
|
|
|
inputRef={(el) => {
|
|
|
|
|
autoCompleteInputRef.current = el;
|
|
|
|
|
}}
|
2025-11-25 10:04:05 -05:00
|
|
|
aria-controls="lyric-search-input"
|
|
|
|
|
/>
|
|
|
|
|
<span
|
|
|
|
|
className={`input-status-icon input-status-icon-${inputStatus}`}
|
|
|
|
|
title={statusTitle}
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
style={{ opacity: inputStatus === "hint" ? 0.4 : 1 }}
|
2025-07-15 14:34:44 -04:00
|
|
|
>
|
2025-11-25 10:04:05 -05:00
|
|
|
<StatusIcon fontSize="small" htmlColor={statusColors[inputStatus]} />
|
|
|
|
|
</span>
|
|
|
|
|
<span className="sr-only" aria-live="polite">
|
|
|
|
|
{statusTitle}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2025-11-25 13:05:37 -05:00
|
|
|
<Button
|
|
|
|
|
onClick={() => handleSearch()}
|
|
|
|
|
className="btn"
|
|
|
|
|
ref={searchButtonRef}
|
|
|
|
|
>
|
2025-07-16 10:06:41 -04:00
|
|
|
Search
|
|
|
|
|
</Button>
|
2025-11-22 21:41:41 -05:00
|
|
|
<div className="mt-4">
|
|
|
|
|
Exclude:<br />
|
|
|
|
|
<div id="exclude-checkboxes">
|
|
|
|
|
<UICheckbox id="excl-Genius" label="Genius" onToggle={toggleExclusion} />
|
2025-11-25 13:05:37 -05:00
|
|
|
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib-Cache" onToggle={toggleExclusion} />
|
2025-11-22 21:41:41 -05:00
|
|
|
<UICheckbox id="excl-Cache" label="Cache" onToggle={toggleExclusion} />
|
|
|
|
|
</div>
|
2025-07-19 08:01:29 -04:00
|
|
|
</div>
|
2025-07-16 10:06:41 -04:00
|
|
|
|
|
|
|
|
{isLoading && (
|
|
|
|
|
<div className="mt-3">
|
|
|
|
|
<CircularProgress variant="plain" color="primary" size="md" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{lyricsResult && (
|
2025-07-31 19:53:45 -04:00
|
|
|
<div className={`lyrics-card lyrics-card-${theme} mt-4 p-4 rounded-md shadow-md`}>
|
2025-11-25 10:04:05 -05:00
|
|
|
<div className="lyrics-toolbar">
|
|
|
|
|
<div className="lyrics-title">
|
2025-07-31 19:53:45 -04:00
|
|
|
{lyricsResult.artist} - {lyricsResult.song}
|
2025-07-16 10:06:41 -04:00
|
|
|
</div>
|
2025-11-25 10:04:05 -05:00
|
|
|
<div className="lyrics-actions">
|
|
|
|
|
<div className="text-size-buttons" aria-label="Lyric text size">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`text-size-btn text-size-large ${textSize === "large" ? "active" : ""}`}
|
|
|
|
|
onClick={() => setTextSize("large")}
|
2025-11-25 13:05:37 -05:00
|
|
|
title="Larger text"
|
2025-11-25 10:04:05 -05:00
|
|
|
>
|
|
|
|
|
Aa
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`text-size-btn ${textSize === "normal" ? "active" : ""}`}
|
|
|
|
|
onClick={() => setTextSize("normal")}
|
2025-11-25 13:05:37 -05:00
|
|
|
title="Default text"
|
2025-11-25 10:04:05 -05:00
|
|
|
>
|
|
|
|
|
Aa
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="plain"
|
|
|
|
|
color="neutral"
|
|
|
|
|
aria-label="Copy lyrics"
|
2025-11-25 13:05:37 -05:00
|
|
|
title="Copy lyrics"
|
2025-11-25 10:04:05 -05:00
|
|
|
className="lyrics-action-button"
|
|
|
|
|
onClick={handleCopyLyrics}
|
|
|
|
|
>
|
|
|
|
|
<ContentCopyIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="plain"
|
|
|
|
|
color="neutral"
|
|
|
|
|
aria-label="Copy lyric link"
|
2025-11-25 13:05:37 -05:00
|
|
|
title="Copy shareable link"
|
2025-11-25 10:04:05 -05:00
|
|
|
className="lyrics-action-button"
|
|
|
|
|
onClick={handleCopyLink}
|
|
|
|
|
>
|
|
|
|
|
<LinkIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={`lyrics-content ${textSize === "large" ? "lyrics-content-large" : ""}`}>
|
2025-07-16 10:06:41 -04:00
|
|
|
<div dangerouslySetInnerHTML={{ __html: lyricsResult.lyrics }} />
|
|
|
|
|
</div>
|
2025-07-31 19:53:45 -04:00
|
|
|
</div>
|
2025-07-16 10:06:41 -04:00
|
|
|
)}
|
2025-06-18 07:46:59 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-15 14:34:44 -04:00
|
|
|
|
2025-07-19 08:01:29 -04:00
|
|
|
export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
|
2025-06-18 07:46:59 -04:00
|
|
|
const [checked, setChecked] = useState(false);
|
2025-07-19 08:01:29 -04:00
|
|
|
|
2025-06-18 07:46:59 -04:00
|
|
|
useImperativeHandle(ref, () => ({
|
|
|
|
|
setChecked: (val) => setChecked(val),
|
2025-06-18 11:41:03 -04:00
|
|
|
checked,
|
2025-06-18 07:46:59 -04:00
|
|
|
}));
|
2025-07-19 08:01:29 -04:00
|
|
|
|
|
|
|
|
const verifyExclusions = () => {
|
2025-07-25 10:25:14 -04:00
|
|
|
const checkboxes = document.querySelectorAll("#exclude-checkboxes input[type=checkbox]");
|
|
|
|
|
const checkedCount = [...checkboxes].filter(cb => cb.checked).length;
|
|
|
|
|
|
2025-07-19 08:01:29 -04:00
|
|
|
if (checkedCount === 3) {
|
2025-07-25 10:25:14 -04:00
|
|
|
checkboxes.forEach(cb => cb.click());
|
2025-09-22 11:15:24 -04:00
|
|
|
if (!toast.isActive("lyrics-exclusion-reset-toast")) {
|
|
|
|
|
toast.error("All sources were excluded; exclusions have been reset.",
|
|
|
|
|
{ toastId: "lyrics-exclusion-reset-toast" }
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-06-18 07:46:59 -04:00
|
|
|
}
|
|
|
|
|
};
|
2025-07-19 08:01:29 -04:00
|
|
|
|
|
|
|
|
const handleChange = (e) => {
|
|
|
|
|
const newChecked = e.target.checked;
|
|
|
|
|
setChecked(newChecked);
|
|
|
|
|
if (props.onToggle) {
|
|
|
|
|
const source = props.label; // Use label as source identifier
|
|
|
|
|
props.onToggle(source);
|
|
|
|
|
}
|
|
|
|
|
verifyExclusions();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<Checkbox
|
|
|
|
|
id={props.id}
|
|
|
|
|
key={props.label}
|
|
|
|
|
checked={checked}
|
|
|
|
|
label={props.label}
|
|
|
|
|
style={{ color: "inherit" }}
|
|
|
|
|
onChange={handleChange}
|
|
|
|
|
/>
|
2025-06-18 07:46:59 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
2025-07-31 19:53:45 -04:00
|
|
|
export function LyricResultBox(opts = {}) {
|
2025-06-18 07:46:59 -04:00
|
|
|
return (
|
2025-07-15 14:34:44 -04:00
|
|
|
<div>
|
2025-07-16 10:06:41 -04:00
|
|
|
<Box className={`lyrics-card lyrics-card-${theme}`} sx={{ p: 2 }}>
|
2025-07-15 14:34:44 -04:00
|
|
|
<div className='lyrics-content'></div>
|
|
|
|
|
{/* <ContentCopyIcon className='lyrics-card-copyButton' size='lg' /> */}
|
|
|
|
|
</Box>
|
|
|
|
|
</div>
|
2025-06-18 07:46:59 -04:00
|
|
|
)
|
|
|
|
|
}
|