- feat: Enhance LyricSearch and Memes components with new features and styling improvements

Bump major version -> 0.3
This commit is contained in:
2025-11-25 10:04:05 -05:00
parent 05aa48af14
commit 8500cd6e67
7 changed files with 587 additions and 88 deletions

View File

@@ -8,11 +8,15 @@ import React, {
useCallback,
} 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";
import IconButton from "@mui/joy/IconButton";
import Checkbox from "@mui/joy/Checkbox";
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
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';
import { AutoComplete } from 'primereact/autocomplete';
import { API_URL } from '../config';
@@ -29,12 +33,11 @@ export default function LyricSearch() {
<LyricSearchInputField
id="lyric-search-input"
placeholder="Artist - Song"
setShowLyrics={setShowLyrics} />
setShowLyrics={setShowLyrics}
/>
<div id="spinner" className="hidden">
<CircularProgress
variant="plain"
color="primary"
size="md" /></div>
<CircularProgress variant="plain" color="primary" size="md" />
</div>
</div>
</div>
);
@@ -45,13 +48,29 @@ export default function LyricSearch() {
export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const [value, setValue] = useState("");
const [suggestions, setSuggestions] = useState([]);
const [alertVisible, setAlertVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [excludedSources, setExcludedSources] = useState([]);
const [lyricsResult, setLyricsResult] = useState(null);
const [textSize, setTextSize] = useState("normal");
const [inputStatus, setInputStatus] = useState("hint");
const searchToastRef = useRef(null);
const autoCompleteRef = useRef(null);
const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light");
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",
};
// Handle URL hash changes and initial load
useEffect(() => {
@@ -142,23 +161,50 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}, 0);
};
const evaluateSearchValue = useCallback((searchValue, shouldUpdate = true) => {
const trimmed = searchValue?.trim() || "";
let status = "hint";
if (!trimmed) {
if (shouldUpdate) setInputStatus(status);
return { status, valid: false };
}
if (!trimmed.includes(" - ")) {
status = "error";
if (shouldUpdate) setInputStatus(status);
return { status, valid: false };
}
const [artist, song] = trimmed.split(" - ", 2).map((v) => v.trim());
if (!artist || !song) {
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();
}
if (!searchValue.includes(" - ")) {
setAlertVisible(true);
const evaluation = evaluateSearchValue(searchValue);
if (!evaluation?.valid) {
const message = statusLabels[evaluation?.status || inputStatus] || "Please use Artist - Song";
toast.error(message);
return;
}
const [artist, song] = searchValue.split(" - ", 2).map((v) => v.trim());
if (!artist || !song) {
setAlertVisible(true);
return;
}
setAlertVisible(false);
const { artist, song } = evaluation;
setIsLoading(true);
setLyricsResult(null);
setShowLyrics(false);
@@ -190,6 +236,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
setTextSize("normal");
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
setShowLyrics(true);
@@ -222,36 +269,80 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}
};
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;
return (
<div>
{alertVisible && (
<Alert
color="danger"
variant="solid"
onClose={() => setAlertVisible(false)}
sx={{ mb: 2 }}
<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}`}
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 }}
>
You must specify both an artist and song to search.
<br />
Format: Artist - Song
</Alert>
)}
<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%' }}
aria-controls="lyric-search-input"
/>
<StatusIcon fontSize="small" htmlColor={statusColors[inputStatus]} />
</span>
<span className="sr-only" aria-live="polite">
{statusTitle}
</span>
</div>
<Button onClick={() => handleSearch()} className="btn">
Search
</Button>
@@ -272,10 +363,50 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
{lyricsResult && (
<div className={`lyrics-card lyrics-card-${theme} mt-4 p-4 rounded-md shadow-md`}>
<div className="lyrics-content">
<div style={{ textAlign: "center", fontWeight: "bold", marginBottom: "1rem" }}>
<div className="lyrics-toolbar">
<div className="lyrics-title">
{lyricsResult.artist} - {lyricsResult.song}
</div>
<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")}
>
Aa
</button>
<button
type="button"
className={`text-size-btn ${textSize === "normal" ? "active" : ""}`}
onClick={() => setTextSize("normal")}
>
Aa
</button>
</div>
<IconButton
size="sm"
variant="plain"
color="neutral"
aria-label="Copy lyrics"
className="lyrics-action-button"
onClick={handleCopyLyrics}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
<IconButton
size="sm"
variant="plain"
color="neutral"
aria-label="Copy lyric link"
className="lyrics-action-button"
onClick={handleCopyLink}
>
<LinkIcon fontSize="small" />
</IconButton>
</div>
</div>
<div className={`lyrics-content ${textSize === "large" ? "lyrics-content-large" : ""}`}>
<div dangerouslySetInnerHTML={{ __html: lyricsResult.lyrics }} />
</div>
</div>