From 8500cd6e67920919955767a11ed335e9cdefb2df Mon Sep 17 00:00:00 2001 From: codey Date: Tue, 25 Nov 2025 10:04:05 -0500 Subject: [PATCH] - feat: Enhance LyricSearch and Memes components with new features and styling improvements Bump major version -> 0.3 --- src/assets/styles/MemeGrid.css | 79 ++++++++++++ src/assets/styles/global.css | 115 ++++++++++++++++- src/components/LyricSearch.jsx | 221 ++++++++++++++++++++++++++------- src/components/Memes.jsx | 184 ++++++++++++++++++++++++--- src/config.js | 2 +- src/layouts/Nav.astro | 72 ++++++++--- src/utils/buildTime.js | 2 +- 7 files changed, 587 insertions(+), 88 deletions(-) diff --git a/src/assets/styles/MemeGrid.css b/src/assets/styles/MemeGrid.css index 2ee82a5..7fbd931 100644 --- a/src/assets/styles/MemeGrid.css +++ b/src/assets/styles/MemeGrid.css @@ -1,3 +1,26 @@ +.meme-dialog-tooltip-wrapper { + position: relative; +} + +.meme-dialog-tooltip { + position: absolute; + bottom: -1.5rem; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: 0.15rem 0.4rem; + border-radius: 4px; + font-size: 0.7rem; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.meme-dialog-tooltip-wrapper:hover .meme-dialog-tooltip { + opacity: 1; +} .grid-container { display: grid; grid-template-columns: repeat(3, 1fr); @@ -22,3 +45,59 @@ text-align: center; margin: 2rem 0; } + +.meme-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.meme-dialog-actions { + display: flex; + gap: 0.5rem; +} + +.meme-dialog-actions button { + padding: 0.2rem 0.5rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.35); + background: transparent; + color: inherit; + cursor: pointer; + font-size: 0.85rem; +} + +.meme-dialog-body { + position: relative; +} + +.meme-dialog-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + border: none; + background: rgba(0, 0, 0, 0.35); + color: #fff; + width: 2.25rem; + height: 2.25rem; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.meme-dialog-nav:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.meme-dialog-nav-prev { + left: 0.5rem; +} + +.meme-dialog-nav-next { + right: 0.5rem; +} diff --git a/src/assets/styles/global.css b/src/assets/styles/global.css index f560aa9..da01f36 100644 --- a/src/assets/styles/global.css +++ b/src/assets/styles/global.css @@ -232,8 +232,35 @@ Custom } #lyric-search-input { - margin-right: 1.5%; - padding-bottom: 1rem; + margin: 0; + padding: 0; +} + +.lyric-search-input-wrapper { + position: relative; + width: 100%; + max-width: 900px; +} + +.lyric-search-input-wrapper .p-autocomplete { + width: 100%; +} + +.lyric-search-input-wrapper .p-autocomplete-input { + padding-right: 2.5rem; +} + +.input-status-icon { + position: absolute; + right: 0.85rem; + top: 0; + bottom: 0; + transform: none; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + transition: opacity 0.2s ease, color 0.2s ease; } #lyrics-info { @@ -253,6 +280,54 @@ Custom transition: background 0.3s; } +.lyrics-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.lyrics-title { + font-weight: 600; + flex: 1; + text-align: left; +} + +.lyrics-actions { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.text-size-buttons { + display: flex; + border: 1px solid rgba(79, 70, 229, 0.25); + border-radius: 999px; + overflow: hidden; + background: rgba(79, 70, 229, 0.06); +} + +.text-size-btn { + background: transparent; + border: none; + color: inherit; + padding: 0.15rem 0.5rem; + font-size: 0.85rem; + cursor: pointer; + transition: background 0.2s, color 0.2s; +} + +.text-size-btn.text-size-large { + font-size: 0.95rem; +} + +.text-size-btn.active { + background: rgba(79, 70, 229, 0.15); + font-weight: 600; +} + .lyrics-content { line-height: 2.0; font-family: 'Inter', sans-serif; @@ -260,6 +335,22 @@ Custom white-space: pre-wrap; } +.lyrics-content-large { + font-size: 1.08rem; + line-height: 1.85; +} + +.lyrics-action-button { + color: inherit; + border: 1px solid transparent; + padding: 0.25rem; +} + +.lyrics-action-button:hover { + border-color: rgba(79, 70, 229, 0.2); + background: rgba(79, 70, 229, 0.08); +} + .lyrics-card-dark { background-color: oklch(from rgba(18, 18, 18, 2.0) calc(l + 0.05) c h); } @@ -298,6 +389,26 @@ Custom outline: none; } +.lyric-search-input.has-error .p-autocomplete-input { + border-color: #f87171; +} + +.lyric-search-input.has-ready .p-autocomplete-input { + border-color: #34d399; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .d-dark > * { background-color: rgba(35, 35, 35, 0.9); color: #ffffff; diff --git a/src/components/LyricSearch.jsx b/src/components/LyricSearch.jsx index c383ef9..7f8d781 100644 --- a/src/components/LyricSearch.jsx +++ b/src/components/LyricSearch.jsx @@ -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() { + setShowLyrics={setShowLyrics} + />
-
+ + ); @@ -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(/(\s*)?/gi, (_match, doubleBreak) => { + return doubleBreak ? "\n\n" : "\n"; + }) + .replace(/]*>/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 (
- {alertVisible && ( - setAlertVisible(false)} - sx={{ mb: 2 }} +
+ 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" + /> + + + {statusTitle} + +
@@ -272,10 +363,50 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) { {lyricsResult && (
-
-
+
+
{lyricsResult.artist} - {lyricsResult.song}
+
+
+ + +
+ + + + + + +
+
+
diff --git a/src/components/Memes.jsx b/src/components/Memes.jsx index 63e7c47..dd6efe9 100644 --- a/src/components/Memes.jsx +++ b/src/components/Memes.jsx @@ -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 ( <>
@@ -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' }} > { {/* Dialog for enlarged image */} + Meme #{selectedImage?.id} - {selectedImage?.timestamp} +
+
+ + + + Copy image +
+
+
+ } 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 && ( - {`meme-${selectedImage.id}`} +
+ + {`meme-${selectedImage.id}`} + +
)} diff --git a/src/config.js b/src/config.js index bae25c2..88cae93 100644 --- a/src/config.js +++ b/src/config.js @@ -14,6 +14,6 @@ export const RADIO_API_URL = "https://radio-api.codey.lol"; export const socialLinks = { }; -export const MAJOR_VERSION = "0.2" +export const MAJOR_VERSION = "0.3" export const RELEASE_FLAG = null; export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod"; \ No newline at end of file diff --git a/src/layouts/Nav.astro b/src/layouts/Nav.astro index 49740b1..5b21e09 100644 --- a/src/layouts/Nav.astro +++ b/src/layouts/Nav.astro @@ -1,31 +1,58 @@ --- -import { metaData } from "../config"; +import { metaData, API_URL } from "../config"; import { Icon } from "astro-icon/components"; -import ExitToApp from '@mui/icons-material/ExitToApp'; const isLoggedIn = Astro.cookies.get('access_token') || Astro.cookies.get('refresh_token'); +const padlockIconSvg = ` + + + + + +`; + +const externalLinkIconSvg = ` + + + +`; + const navItems = [ { label: "Home", href: "/" }, { label: "Radio", href: "/radio" }, { label: "Memes", href: "/memes" }, - { label: "Lighting", href: "/lighting", auth: 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 }, - // ...(isLoggedIn ? [{ label: "Logout", href: "#", onClick: "handleLogout()" }] : []), # todo + { label: "Lighting", href: "/lighting", auth: true, icon: "padlock" }, + { label: "TRip", href: "/TRip", auth: true, icon: "padlock" }, + { label: "Status", href: "https://status.boatson.boats", icon: "external" }, + { label: "Git", href: "https://kode.boatson.boats", icon: "external" }, + ...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []), ]; const currentPath = Astro.url.pathname; + --- -