- feat: Enhance LyricSearch and Memes components with new features and styling improvements
Bump major version -> 0.3
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Image } from 'primereact/image';
|
||||
import IconButton from '@mui/joy/IconButton';
|
||||
import FileCopyRoundedIcon from '@mui/icons-material/FileCopyRounded';
|
||||
import { toast } from 'react-toastify';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
const MEME_API_URL = `${API_URL}/memes/list_memes`;
|
||||
@@ -13,10 +16,77 @@ const Memes = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const observerRef = useRef();
|
||||
const theme = document.documentElement.getAttribute("data-theme")
|
||||
const cacheRef = useRef({ pagesLoaded: new Set(), items: [] });
|
||||
|
||||
const prefetchImage = useCallback((img) => {
|
||||
if (!img || typeof window === "undefined") return;
|
||||
const preload = new window.Image();
|
||||
preload.src = img.url;
|
||||
}, []);
|
||||
|
||||
const handleNavigate = useCallback((direction) => {
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev + direction;
|
||||
if (newIndex < 0 || newIndex >= images.length) {
|
||||
return prev;
|
||||
}
|
||||
const nextImage = images[newIndex];
|
||||
setSelectedImage(nextImage);
|
||||
prefetchImage(images[newIndex + 1]);
|
||||
return newIndex;
|
||||
});
|
||||
}, [images, prefetchImage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedImage) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
handleNavigate(-1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
handleNavigate(1);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [selectedImage, handleNavigate]);
|
||||
useEffect(() => {
|
||||
const cached = sessionStorage.getItem("memes-cache");
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
cacheRef.current = {
|
||||
pagesLoaded: new Set(parsed.pagesLoaded || []),
|
||||
items: parsed.items || [],
|
||||
};
|
||||
setImages(parsed.items || []);
|
||||
setPage(parsed.nextPage || 1);
|
||||
setHasMore(parsed.hasMore ?? true);
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse meme cache", e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
const persistCache = useCallback((nextPage) => {
|
||||
sessionStorage.setItem("memes-cache", JSON.stringify({
|
||||
items: cacheRef.current.items,
|
||||
pagesLoaded: Array.from(cacheRef.current.pagesLoaded),
|
||||
nextPage,
|
||||
hasMore,
|
||||
}));
|
||||
}, [hasMore]);
|
||||
|
||||
const loadImages = async (pageNum, attempt = 0) => {
|
||||
if (loading || !hasMore) return;
|
||||
if (loading || !hasMore || cacheRef.current.pagesLoaded.has(pageNum)) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${MEME_API_URL}?page=${pageNum}`);
|
||||
@@ -47,8 +117,14 @@ const Memes = () => {
|
||||
url: `${BASE_IMAGE_URL}/${m.id}.png`,
|
||||
}));
|
||||
|
||||
setImages(prev => [...prev, ...imageObjects]);
|
||||
setPage(prev => prev + 1);
|
||||
cacheRef.current.pagesLoaded.add(pageNum);
|
||||
cacheRef.current.items = [...cacheRef.current.items, ...imageObjects];
|
||||
setImages(cacheRef.current.items);
|
||||
setPage(prev => {
|
||||
const next = prev + 1;
|
||||
persistCache(next);
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to load memes", e);
|
||||
toast.error("Failed to load more memes.");
|
||||
@@ -69,9 +145,37 @@ const Memes = () => {
|
||||
}, [loading, hasMore, page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadImages(1);
|
||||
if (!cacheRef.current.pagesLoaded.size) {
|
||||
loadImages(1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const canGoPrev = selectedIndex > 0;
|
||||
const canGoNext = selectedIndex >= 0 && selectedIndex < images.length - 1;
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setSelectedImage(null);
|
||||
setSelectedIndex(-1);
|
||||
}, []);
|
||||
|
||||
const handleCopyImage = useCallback(async () => {
|
||||
if (!selectedImage) return;
|
||||
try {
|
||||
const res = await fetch(selectedImage.url, { mode: "cors" });
|
||||
const blob = await res.blob();
|
||||
if (!navigator.clipboard?.write) {
|
||||
throw new Error("Clipboard API not available");
|
||||
}
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ [blob.type || "image/png"]: blob })
|
||||
]);
|
||||
toast.success("Image copied", { autoClose: 1500 });
|
||||
} catch (err) {
|
||||
console.error("Copy image failed", err);
|
||||
toast.error("Unable to copy image");
|
||||
}
|
||||
}, [selectedImage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid-container">
|
||||
@@ -82,7 +186,11 @@ const Memes = () => {
|
||||
key={img.id}
|
||||
className="grid-item"
|
||||
ref={isLast ? lastImageRef : null}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
onClick={() => {
|
||||
setSelectedImage(img);
|
||||
setSelectedIndex(i);
|
||||
prefetchImage(images[i + 1]);
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Image
|
||||
@@ -103,9 +211,27 @@ const Memes = () => {
|
||||
|
||||
{/* Dialog for enlarged image */}
|
||||
<Dialog
|
||||
header={`Meme #${selectedImage?.id} - ${selectedImage?.timestamp}`}
|
||||
header={
|
||||
<div className="meme-dialog-header">
|
||||
<span>Meme #{selectedImage?.id} - {selectedImage?.timestamp}</span>
|
||||
<div className="meme-dialog-actions">
|
||||
<div className="meme-dialog-tooltip-wrapper">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
color="neutral"
|
||||
aria-label="Copy image"
|
||||
onClick={handleCopyImage}
|
||||
>
|
||||
<FileCopyRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<span className="meme-dialog-tooltip">Copy image</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
visible={!!selectedImage}
|
||||
onHide={() => setSelectedImage(null)}
|
||||
onHide={closeDialog}
|
||||
style={{ width: '90vw', maxWidth: '720px' }}
|
||||
className={`d-${theme}`}
|
||||
modal
|
||||
@@ -113,18 +239,38 @@ const Memes = () => {
|
||||
dismissableMask={true}
|
||||
>
|
||||
{selectedImage && (
|
||||
<img
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
<div className="meme-dialog-body">
|
||||
<button
|
||||
type="button"
|
||||
className="meme-dialog-nav meme-dialog-nav-prev"
|
||||
onClick={() => handleNavigate(-1)}
|
||||
disabled={!canGoPrev}
|
||||
aria-label="Previous meme"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<img
|
||||
src={selectedImage.url}
|
||||
alt={`meme-${selectedImage.id}`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '70vh',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="meme-dialog-nav meme-dialog-nav-next"
|
||||
onClick={() => handleNavigate(1)}
|
||||
disabled={!canGoNext}
|
||||
aria-label="Next meme"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user