refactor/add build time to page footers

This commit is contained in:
2025-07-16 10:06:41 -04:00
parent 289411c8eb
commit 8f7b0f2719
12 changed files with 28964 additions and 127 deletions

7396
public/themes/bootstrap-dark.css vendored Normal file

File diff suppressed because one or more lines are too long

7292
public/themes/bootstrap-light.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
@import "tailwindcss"; @import "tailwindcss";
@import "primereact/resources/themes/bootstrap4-dark-blue/theme.css";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@ -161,6 +160,11 @@ blockquote p:first-of-type::after {
Custom Custom
*/ */
.footer {
display: grid;
align-items: end;
}
.header-text, .footer-text { .header-text, .footer-text {
font-family: "bebas_neueregular"; font-family: "bebas_neueregular";
} }

View File

@ -8,7 +8,8 @@ import Alert from '@mui/joy/Alert';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import CustomToastContainer from '../components/ToastProvider.jsx'; import CustomToastContainer from '../components/ToastProvider.jsx';
import LyricSearch from './LyricSearch.jsx'; import LyricSearch from './LyricSearch.jsx';
import 'primereact/resources/themes/bootstrap4-light-blue/theme.css'; // TEMP
import 'primereact/resources/primereact.min.css';
export default function Root({child}) { export default function Root({child}) {
window.toast = toast; window.toast = toast;
@ -34,3 +35,4 @@ export default function Root({child}) {
</PrimeReactProvider> </PrimeReactProvider>
); );
} }

View File

@ -9,6 +9,9 @@ import { metaData } from "../config";
import { SEO } from "astro-seo"; import { SEO } from "astro-seo";
import { getImagePath } from "astro-opengraph-images"; import { getImagePath } from "astro-opengraph-images";
import { JoyUIRootIsland } from "./Components" import { JoyUIRootIsland } from "./Components"
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr"; // your existing theme hook
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
const { title, description = metaData.description, image } = Astro.props; const { title, description = metaData.description, image } = Astro.props;
const { url, site } = Astro; const { url, site } = Astro;
@ -44,4 +47,5 @@ const openGraphImage = image
// extending the default link tags // extending the default link tags
link: [{ rel: "icon", href: "https://codey.lol/images/favicon.png" }], link: [{ rel: "icon", href: "https://codey.lol/images/favicon.png" }],
}} }}
/> />

View File

@ -1,17 +1,21 @@
--- ---
import { metaData, API_URL } from "../config"; import { metaData, API_URL } from "../config";
import RandomMsg from "../components/RandomMsg"; import RandomMsg from "../components/RandomMsg";
import { buildTime } from '../utils/buildTime.js';
const YEAR = new Date().getFullYear(); const YEAR = new Date().getFullYear();
--- ---
<div class="footer">
<small class="block lg:mt-24 mt-16 text-[#1C1C1C] dark:text-[#D4D4D4] footer-text"> <small class="block lg:mt-24 mt-16 text-[#1C1C1C] dark:text-[#D4D4D4] footer-text">
<time>© {YEAR}</time>{" "} <time>© {YEAR}</time>{" "}
{metaData.owner} {metaData.owner}
</a> </a>
</small> </small>
<RandomMsg client:only="react" /> <RandomMsg client:only="react" />
<div style="margin-top: 15px; bottom: 0%">
<small>Built: {buildTime} UTC</small>
</div>
</div>
<style> <style>
@media screen and (max-width: 480px) { @media screen and (max-width: 480px) {
article { article {

View File

@ -19,6 +19,7 @@ import { api as API_URL } from '../config';
window.$ = window.jQuery = jQuery; window.$ = window.jQuery = jQuery;
const theme = document.documentElement.getAttribute("data-theme") const theme = document.documentElement.getAttribute("data-theme")
document.addEventListener('set-theme', (e) => { document.addEventListener('set-theme', (e) => {
const box = document.querySelector("[class*='lyrics-card-']") const box = document.querySelector("[class*='lyrics-card-']")
let removedClass = "lyrics-card-dark"; let removedClass = "lyrics-card-dark";
@ -31,6 +32,7 @@ document.addEventListener('set-theme', (e) => {
}); });
export default function LyricSearch() { export default function LyricSearch() {
const [showLyrics, setShowLyrics] = useState(false);
return ( return (
<div className="lyric-search"> <div className="lyric-search">
<h2 className="title"> <h2 className="title">
@ -40,7 +42,8 @@ export default function LyricSearch() {
<label>Search:</label> <label>Search:</label>
<LyricSearchInputField <LyricSearchInputField
id="lyric-search-input" id="lyric-search-input"
placeholder="Artist - Song" /> placeholder="Artist - Song"
setShowLyrics={setShowLyrics} />
<br /> <br />
Exclude:<br /> Exclude:<br />
<div id="exclude-checkboxes"> <div id="exclude-checkboxes">
@ -54,159 +57,142 @@ export default function LyricSearch() {
color="primary" color="primary"
size="md"/></div> size="md"/></div>
</div> </div>
<LyricResultBox/>
</div> </div>
); );
} }
export function LyricSearchInputField(opts = {}) {
export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
const [showAlert, setShowAlert] = useState(false); const [alertVisible, setAlertVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [excludedSources, setExcludedSources] = useState([]);
const [lyricsResult, setLyricsResult] = useState(null);
const autoCompleteRef = useRef(null); const autoCompleteRef = useRef(null);
// Ensure the dropdown panel is scrollable after it shows // Typeahead: fetch suggestions
const fetchSuggestions = async (event) => {
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);
};
// Toggle exclusion state for checkboxes
const toggleExclusion = (source) => {
setExcludedSources((prev) =>
prev.includes(source)
? prev.filter((s) => s !== source)
: [...prev, source]
);
};
// Show scrollable dropdown panel with mouse wheel handling
const handlePanelShow = () => { const handlePanelShow = () => {
setTimeout(() => { setTimeout(() => {
const panel = document.querySelector(".p-autocomplete-panel"); const panel = document.querySelector(".p-autocomplete-panel");
const items = panel?.querySelector(".p-autocomplete-items"); const items = panel?.querySelector(".p-autocomplete-items");
if (!items) return; if (items) {
items.style.maxHeight = "200px";
items.style.overflowY = "auto";
items.style.overscrollBehavior = "contain";
items.style.maxHeight = "200px"; const wheelHandler = (e) => {
items.style.overflowY = "auto"; const delta = e.deltaY;
items.style.overscrollBehavior = "contain"; const atTop = items.scrollTop === 0;
const atBottom =
items.scrollTop + items.clientHeight >= items.scrollHeight;
// ✅ Attach wheel scroll manually if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
const wheelHandler = (e) => { e.preventDefault();
const delta = e.deltaY; } else {
const atTop = items.scrollTop === 0; e.stopPropagation();
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight; }
};
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) { items.removeEventListener("wheel", wheelHandler);
e.preventDefault(); // prevent outer scroll items.addEventListener("wheel", wheelHandler, { passive: false });
} else { }
e.stopPropagation(); // prevent parent scroll
}
};
// Clean up first, then re-add
items.removeEventListener('wheel', wheelHandler);
items.addEventListener('wheel', wheelHandler, { passive: false });
// Cleanup on hide
const observer = new MutationObserver(() => {
if (!document.body.contains(items)) {
items.removeEventListener('wheel', wheelHandler);
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}, 0); }, 0);
}; };
const handleSearch = async () => {
const typeahead_search = (event) => {
const query = event.query;
$.ajax({
url: `${API_URL}/typeahead/lyrics`,
method: 'POST',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify({ query }),
dataType: 'json',
success: setSuggestions
});
};
const handleSearch = () => {
if (autoCompleteRef.current) { if (autoCompleteRef.current) {
autoCompleteRef.current.hide(); autoCompleteRef.current.hide();
} }
const validSearch = value.includes(" - "); if (!value.includes(" - ")) {
if (!validSearch) { setAlertVisible(true);
setShowAlert(true);
setTimeout(() => setShowAlert(false), 5000);
return; return;
} }
const [rawArtist, rawSong] = value.split(" - ", 2); const [artist, song] = value.split(" - ", 2).map((v) => v.trim());
const search_artist = rawArtist?.trim(); if (!artist || !song) {
const search_song = rawSong?.trim(); setAlertVisible(true);
if (!search_artist || !search_song) {
setShowAlert(true);
setTimeout(() => setShowAlert(false), 5000);
return; return;
} }
const box = $("[class*='lyrics-card-']"); setAlertVisible(false);
const lyrics_content = $(".lyrics-content"); setIsLoading(true);
const spinner = $("#spinner"); setLyricsResult(null);
const excluded_sources = []; setShowLyrics(false);
$("#exclude-checkboxes input:checked").each(function () { const toastId = toast.info("Searching...", {
excluded_sources.push(this.id.replace("excl-", "").toLowerCase()); style: {
color: "#000",
backgroundColor: "rgba(217, 242, 255, 0.8)",
},
}); });
setShowAlert(false); const startTime = Date.now();
$("#alert").addClass("hidden");
spinner.removeClass("hidden");
box.addClass("hidden");
const start_time = Date.now(); try {
const search_toast = toast.info("Searching...", { const res = await fetch(`${API_URL}/lyric/search`, {
style: { color: '#000', backgroundColor: 'rgba(217, 242, 255, 0.8)' } method: "POST",
}); headers: { "Content-Type": "application/json" },
body: JSON.stringify({
a: artist,
s: song,
excluded_sources: excludedSources,
src: "Web",
extra: true,
}),
});
$.ajax({ const data = await res.json();
url: `${API_URL}/lyric/search`, if (!res.ok || !data.lyrics) {
method: 'POST', throw new Error(data.errorText || "Unknown error.");
contentType: 'application/json; charset=utf-8',
data: JSON.stringify({
a: search_artist,
s: search_song,
excluded_sources,
src: 'Web',
extra: true,
})
}).done((data) => {
spinner.addClass("hidden");
if (data.err || !data.lyrics) {
return toast.update(search_toast, {
render: `🙁 ${data.errorText}`,
type: "",
style: { backgroundColor: "rgba(255, 0, 0, 0.5)" },
hideProgressBar: true,
autoClose: 5000,
});
} }
const duration = ((Date.now() - start_time) / 1000).toFixed(1); const duration = ((Date.now() - startTime) / 1000).toFixed(1);
lyrics_content.html(`<span id='lyrics-info'>${data.artist} - ${data.song}</span>${data.lyrics}`); setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
box.removeClass("hidden"); setShowLyrics(true);
toast.update(search_toast, { toast.update(toastId, {
render: `🦄 Found! (Took ${duration}s)`, render: `🦄 Found! (Took ${duration}s)`,
type: "", type: "",
style: { backgroundColor: "rgba(46, 186, 106, 1)" }, style: { backgroundColor: "rgba(46, 186, 106, 1)" },
autoClose: 2000, autoClose: 2000,
hideProgressBar: true, hideProgressBar: true,
}); });
}).fail((jqXHR) => { } catch (error) {
spinner.addClass("hidden"); toast.update(toastId, {
const msg = `😕 Failed to reach search endpoint (${jqXHR.status})` + render: `😕 ${error.message}`,
(jqXHR.responseJSON?.detail ? `\n${jqXHR.responseJSON.detail}` : "");
toast.update(search_toast, {
render: msg,
type: "", type: "",
style: { backgroundColor: "rgba(255, 0, 0, 0.5)" }, style: { backgroundColor: "rgba(255, 0, 0, 0.5)" },
hideProgressBar: true,
autoClose: 5000, autoClose: 5000,
hideProgressBar: true,
}); });
}); } finally {
setIsLoading(false);
}
}; };
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
@ -218,30 +204,52 @@ export function LyricSearchInputField(opts = {}) {
return ( return (
<div> <div>
{showAlert && ( {alertVisible && (
<Alert <Alert
color="danger" color="danger"
variant="solid" variant="solid"
onClose={() => setShowAlert(false)} onClose={() => setAlertVisible(false)}
sx={{ mb: 2 }}
> >
You must specify both an artist and song to search. You must specify both an artist and song to search.
<br /> <br />
Format: Artist - Song Format: Artist - Song
</Alert> </Alert>
)} )}
<AutoComplete <AutoComplete
id={opts.id} id={id}
ref={autoCompleteRef} ref={autoCompleteRef}
value={value} value={value}
size={40}
suggestions={suggestions} suggestions={suggestions}
completeMethod={typeahead_search} completeMethod={fetchSuggestions}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onShow={handlePanelShow} onShow={handlePanelShow}
placeholder={opts.placeholder} placeholder={placeholder}
autoFocus /> autoFocus
<Button onClick={handleSearch} className="btn">Search</Button> size={40}
/>
<Button onClick={handleSearch} className="btn">
Search
</Button>
{isLoading && (
<div className="mt-3">
<CircularProgress variant="plain" color="primary" size="md" />
</div>
)}
{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" }}>
{lyricsResult.artist} - {lyricsResult.song}
</div>
<div dangerouslySetInnerHTML={{ __html: lyricsResult.lyrics }} />
</div>
</div>
)}
</div> </div>
); );
} }
@ -291,7 +299,7 @@ export const UICheckbox = forwardRef(function UICheckbox(opts = {}, ref) {
export function LyricResultBox(opts={}) { export function LyricResultBox(opts={}) {
return ( return (
<div> <div>
<Box className={`lyrics-card lyrics-card-${theme} hidden`} sx={{ p: 2 }}> <Box className={`lyrics-card lyrics-card-${theme}`} sx={{ p: 2 }}>
<div className='lyrics-content'></div> <div className='lyrics-content'></div>
{/* <ContentCopyIcon className='lyrics-card-copyButton' size='lg' /> */} {/* <ContentCopyIcon className='lyrics-card-copyButton' size='lg' /> */}
</Box> </Box>

View File

@ -0,0 +1,13 @@
export function useHtmlThemeAttr () {
const [theme, setTheme] = useState(() =>
document.documentElement.getAttribute("data-theme") || "light"
);
useEffect(() => {
const handler = (e) => setTheme(e.detail);
document.addEventListener("set-theme", handler);
return () => document.removeEventListener("set-theme", handler);
}, []);
return theme;
}

View File

@ -0,0 +1,17 @@
import { useEffect } from "react";
export function usePrimeReactThemeSwitcher(theme) {
useEffect(() => {
const themeLink = document.getElementById("primereact-theme");
if (!themeLink) return;
const newTheme =
theme === "dark"
? "/themes/bootstrap4-dark-blue/theme.css"
: "/themes/bootstrap4-light-blue/theme.css";
if (themeLink.href !== newTheme) {
themeLink.href = newTheme;
}
}, [theme]);
}

3
src/utils/buildTime.js Normal file
View File

@ -0,0 +1,3 @@
export const buildTime = new Date().toLocaleString(undefined, {
timeZone: "UTC",
});