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

View File

@ -8,7 +8,8 @@ import Alert from '@mui/joy/Alert';
import WarningIcon from '@mui/icons-material/Warning';
import CustomToastContainer from '../components/ToastProvider.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}) {
window.toast = toast;
@ -34,3 +35,4 @@ export default function Root({child}) {
</PrimeReactProvider>
);
}

View File

@ -9,6 +9,9 @@ import { metaData } from "../config";
import { SEO } from "astro-seo";
import { getImagePath } from "astro-opengraph-images";
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 { url, site } = Astro;
@ -45,3 +48,4 @@ const openGraphImage = image
link: [{ rel: "icon", href: "https://codey.lol/images/favicon.png" }],
}}
/>

View File

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

View File

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