refactor/add build time to page footers
This commit is contained in:
7396
public/themes/bootstrap-dark.css
vendored
Normal file
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
7292
public/themes/bootstrap-light.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7047
public/themes/bootstrap4-dark-blue/theme.css
Normal file
7047
public/themes/bootstrap4-dark-blue/theme.css
Normal file
File diff suppressed because it is too large
Load Diff
7047
public/themes/bootstrap4-light-blue/theme.css
Normal file
7047
public/themes/bootstrap4-light-blue/theme.css
Normal file
File diff suppressed because it is too large
Load Diff
@ -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";
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
@ -45,3 +48,4 @@ const openGraphImage = image
|
|||||||
link: [{ rel: "icon", href: "https://codey.lol/images/favicon.png" }],
|
link: [{ rel: "icon", href: "https://codey.lol/images/favicon.png" }],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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.maxHeight = "200px";
|
||||||
items.style.overflowY = "auto";
|
items.style.overflowY = "auto";
|
||||||
items.style.overscrollBehavior = "contain";
|
items.style.overscrollBehavior = "contain";
|
||||||
|
|
||||||
// ✅ Attach wheel scroll manually
|
|
||||||
const wheelHandler = (e) => {
|
const wheelHandler = (e) => {
|
||||||
const delta = e.deltaY;
|
const delta = e.deltaY;
|
||||||
const atTop = items.scrollTop === 0;
|
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)) {
|
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
|
||||||
e.preventDefault(); // prevent outer scroll
|
e.preventDefault();
|
||||||
} else {
|
} else {
|
||||||
e.stopPropagation(); // prevent parent scroll
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clean up first, then re-add
|
items.removeEventListener("wheel", wheelHandler);
|
||||||
items.removeEventListener('wheel', wheelHandler);
|
items.addEventListener("wheel", wheelHandler, { passive: false });
|
||||||
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({
|
||||||
$.ajax({
|
a: artist,
|
||||||
url: `${API_URL}/lyric/search`,
|
s: song,
|
||||||
method: 'POST',
|
excluded_sources: excludedSources,
|
||||||
contentType: 'application/json; charset=utf-8',
|
src: "Web",
|
||||||
data: JSON.stringify({
|
|
||||||
a: search_artist,
|
|
||||||
s: search_song,
|
|
||||||
excluded_sources,
|
|
||||||
src: 'Web',
|
|
||||||
extra: true,
|
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);
|
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>
|
||||||
|
13
src/hooks/useHtmlThemeAttr.jsx
Normal file
13
src/hooks/useHtmlThemeAttr.jsx
Normal 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;
|
||||||
|
}
|
17
src/hooks/usePrimeReactThemeSwitcher.jsx
Normal file
17
src/hooks/usePrimeReactThemeSwitcher.jsx
Normal 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
3
src/utils/buildTime.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const buildTime = new Date().toLocaleString(undefined, {
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
Reference in New Issue
Block a user