- feat: Enhance LyricSearch and Memes components with new features and styling improvements
Bump major version -> 0.3
This commit is contained in:
@@ -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 {
|
.grid-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
@@ -22,3 +45,59 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 2rem 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -232,8 +232,35 @@ Custom
|
|||||||
}
|
}
|
||||||
|
|
||||||
#lyric-search-input {
|
#lyric-search-input {
|
||||||
margin-right: 1.5%;
|
margin: 0;
|
||||||
padding-bottom: 1rem;
|
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 {
|
#lyrics-info {
|
||||||
@@ -253,6 +280,54 @@ Custom
|
|||||||
transition: background 0.3s;
|
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 {
|
.lyrics-content {
|
||||||
line-height: 2.0;
|
line-height: 2.0;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
@@ -260,6 +335,22 @@ Custom
|
|||||||
white-space: pre-wrap;
|
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 {
|
.lyrics-card-dark {
|
||||||
background-color: oklch(from rgba(18, 18, 18, 2.0) calc(l + 0.05) c h);
|
background-color: oklch(from rgba(18, 18, 18, 2.0) calc(l + 0.05) c h);
|
||||||
}
|
}
|
||||||
@@ -298,6 +389,26 @@ Custom
|
|||||||
outline: none;
|
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 > * {
|
.d-dark > * {
|
||||||
background-color: rgba(35, 35, 35, 0.9);
|
background-color: rgba(35, 35, 35, 0.9);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import Alert from '@mui/joy/Alert';
|
|
||||||
import Box from '@mui/joy/Box';
|
import Box from '@mui/joy/Box';
|
||||||
import Button from "@mui/joy/Button";
|
import Button from "@mui/joy/Button";
|
||||||
|
import IconButton from "@mui/joy/IconButton";
|
||||||
import Checkbox from "@mui/joy/Checkbox";
|
import Checkbox from "@mui/joy/Checkbox";
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
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 { AutoComplete } from 'primereact/autocomplete';
|
||||||
import { API_URL } from '../config';
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
@@ -29,12 +33,11 @@ export default function LyricSearch() {
|
|||||||
<LyricSearchInputField
|
<LyricSearchInputField
|
||||||
id="lyric-search-input"
|
id="lyric-search-input"
|
||||||
placeholder="Artist - Song"
|
placeholder="Artist - Song"
|
||||||
setShowLyrics={setShowLyrics} />
|
setShowLyrics={setShowLyrics}
|
||||||
|
/>
|
||||||
<div id="spinner" className="hidden">
|
<div id="spinner" className="hidden">
|
||||||
<CircularProgress
|
<CircularProgress variant="plain" color="primary" size="md" />
|
||||||
variant="plain"
|
</div>
|
||||||
color="primary"
|
|
||||||
size="md" /></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -45,13 +48,29 @@ export default function LyricSearch() {
|
|||||||
export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
const [alertVisible, setAlertVisible] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [excludedSources, setExcludedSources] = useState([]);
|
const [excludedSources, setExcludedSources] = useState([]);
|
||||||
const [lyricsResult, setLyricsResult] = useState(null);
|
const [lyricsResult, setLyricsResult] = useState(null);
|
||||||
|
const [textSize, setTextSize] = useState("normal");
|
||||||
|
const [inputStatus, setInputStatus] = useState("hint");
|
||||||
const searchToastRef = useRef(null);
|
const searchToastRef = useRef(null);
|
||||||
const autoCompleteRef = useRef(null);
|
const autoCompleteRef = useRef(null);
|
||||||
const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light");
|
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
|
// Handle URL hash changes and initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -142,23 +161,50 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
}, 0);
|
}, 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) => {
|
const handleSearch = async (searchValue = value) => {
|
||||||
if (autoCompleteRef.current) {
|
if (autoCompleteRef.current) {
|
||||||
autoCompleteRef.current.hide();
|
autoCompleteRef.current.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!searchValue.includes(" - ")) {
|
const evaluation = evaluateSearchValue(searchValue);
|
||||||
setAlertVisible(true);
|
if (!evaluation?.valid) {
|
||||||
|
const message = statusLabels[evaluation?.status || inputStatus] || "Please use Artist - Song";
|
||||||
|
toast.error(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [artist, song] = searchValue.split(" - ", 2).map((v) => v.trim());
|
const { artist, song } = evaluation;
|
||||||
if (!artist || !song) {
|
|
||||||
setAlertVisible(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAlertVisible(false);
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setLyricsResult(null);
|
setLyricsResult(null);
|
||||||
setShowLyrics(false);
|
setShowLyrics(false);
|
||||||
@@ -190,6 +236,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
setTextSize("normal");
|
||||||
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
|
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
|
||||||
setShowLyrics(true);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{alertVisible && (
|
<div className="lyric-search-input-wrapper">
|
||||||
<Alert
|
<AutoComplete
|
||||||
color="danger"
|
id={id}
|
||||||
variant="solid"
|
ref={autoCompleteRef}
|
||||||
onClose={() => setAlertVisible(false)}
|
value={value}
|
||||||
sx={{ mb: 2 }}
|
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.
|
<StatusIcon fontSize="small" htmlColor={statusColors[inputStatus]} />
|
||||||
<br />
|
</span>
|
||||||
Format: Artist - Song
|
<span className="sr-only" aria-live="polite">
|
||||||
</Alert>
|
{statusTitle}
|
||||||
)}
|
</span>
|
||||||
|
</div>
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
<Button onClick={() => handleSearch()} className="btn">
|
<Button onClick={() => handleSearch()} className="btn">
|
||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
@@ -272,10 +363,50 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
|
|
||||||
{lyricsResult && (
|
{lyricsResult && (
|
||||||
<div className={`lyrics-card lyrics-card-${theme} mt-4 p-4 rounded-md shadow-md`}>
|
<div className={`lyrics-card lyrics-card-${theme} mt-4 p-4 rounded-md shadow-md`}>
|
||||||
<div className="lyrics-content">
|
<div className="lyrics-toolbar">
|
||||||
<div style={{ textAlign: "center", fontWeight: "bold", marginBottom: "1rem" }}>
|
<div className="lyrics-title">
|
||||||
{lyricsResult.artist} - {lyricsResult.song}
|
{lyricsResult.artist} - {lyricsResult.song}
|
||||||
</div>
|
</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 dangerouslySetInnerHTML={{ __html: lyricsResult.lyrics }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { useEffect, useState, useRef, useCallback } from "react";
|
|||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { Image } from 'primereact/image';
|
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';
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
const MEME_API_URL = `${API_URL}/memes/list_memes`;
|
const MEME_API_URL = `${API_URL}/memes/list_memes`;
|
||||||
@@ -13,10 +16,77 @@ const Memes = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
const observerRef = useRef();
|
const observerRef = useRef();
|
||||||
const theme = document.documentElement.getAttribute("data-theme")
|
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) => {
|
const loadImages = async (pageNum, attempt = 0) => {
|
||||||
if (loading || !hasMore) return;
|
if (loading || !hasMore || cacheRef.current.pagesLoaded.has(pageNum)) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${MEME_API_URL}?page=${pageNum}`);
|
const res = await fetch(`${MEME_API_URL}?page=${pageNum}`);
|
||||||
@@ -47,8 +117,14 @@ const Memes = () => {
|
|||||||
url: `${BASE_IMAGE_URL}/${m.id}.png`,
|
url: `${BASE_IMAGE_URL}/${m.id}.png`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setImages(prev => [...prev, ...imageObjects]);
|
cacheRef.current.pagesLoaded.add(pageNum);
|
||||||
setPage(prev => prev + 1);
|
cacheRef.current.items = [...cacheRef.current.items, ...imageObjects];
|
||||||
|
setImages(cacheRef.current.items);
|
||||||
|
setPage(prev => {
|
||||||
|
const next = prev + 1;
|
||||||
|
persistCache(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load memes", e);
|
console.error("Failed to load memes", e);
|
||||||
toast.error("Failed to load more memes.");
|
toast.error("Failed to load more memes.");
|
||||||
@@ -69,9 +145,37 @@ const Memes = () => {
|
|||||||
}, [loading, hasMore, page]);
|
}, [loading, hasMore, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid-container">
|
<div className="grid-container">
|
||||||
@@ -82,7 +186,11 @@ const Memes = () => {
|
|||||||
key={img.id}
|
key={img.id}
|
||||||
className="grid-item"
|
className="grid-item"
|
||||||
ref={isLast ? lastImageRef : null}
|
ref={isLast ? lastImageRef : null}
|
||||||
onClick={() => setSelectedImage(img)}
|
onClick={() => {
|
||||||
|
setSelectedImage(img);
|
||||||
|
setSelectedIndex(i);
|
||||||
|
prefetchImage(images[i + 1]);
|
||||||
|
}}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -103,9 +211,27 @@ const Memes = () => {
|
|||||||
|
|
||||||
{/* Dialog for enlarged image */}
|
{/* Dialog for enlarged image */}
|
||||||
<Dialog
|
<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}
|
visible={!!selectedImage}
|
||||||
onHide={() => setSelectedImage(null)}
|
onHide={closeDialog}
|
||||||
style={{ width: '90vw', maxWidth: '720px' }}
|
style={{ width: '90vw', maxWidth: '720px' }}
|
||||||
className={`d-${theme}`}
|
className={`d-${theme}`}
|
||||||
modal
|
modal
|
||||||
@@ -113,18 +239,38 @@ const Memes = () => {
|
|||||||
dismissableMask={true}
|
dismissableMask={true}
|
||||||
>
|
>
|
||||||
{selectedImage && (
|
{selectedImage && (
|
||||||
<img
|
<div className="meme-dialog-body">
|
||||||
src={selectedImage.url}
|
<button
|
||||||
alt={`meme-${selectedImage.id}`}
|
type="button"
|
||||||
style={{
|
className="meme-dialog-nav meme-dialog-nav-prev"
|
||||||
maxWidth: '100%',
|
onClick={() => handleNavigate(-1)}
|
||||||
maxHeight: '70vh', // restrict height to viewport height
|
disabled={!canGoPrev}
|
||||||
objectFit: 'contain',
|
aria-label="Previous meme"
|
||||||
display: 'block',
|
>
|
||||||
margin: '0 auto',
|
‹
|
||||||
borderRadius: '6px',
|
</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>
|
</Dialog>
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ export const RADIO_API_URL = "https://radio-api.codey.lol";
|
|||||||
export const socialLinks = {
|
export const socialLinks = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MAJOR_VERSION = "0.2"
|
export const MAJOR_VERSION = "0.3"
|
||||||
export const RELEASE_FLAG = null;
|
export const RELEASE_FLAG = null;
|
||||||
export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod";
|
export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod";
|
||||||
@@ -1,31 +1,58 @@
|
|||||||
---
|
---
|
||||||
import { metaData } from "../config";
|
import { metaData, API_URL } from "../config";
|
||||||
import { Icon } from "astro-icon/components";
|
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 isLoggedIn = Astro.cookies.get('access_token') || Astro.cookies.get('refresh_token');
|
||||||
|
|
||||||
|
const padlockIconSvg = `
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.5 11V9a3.5 3.5 0 1 1 7 0v2" />
|
||||||
|
<rect x="7" y="11" width="10" height="8.5" rx="1.8" />
|
||||||
|
<circle cx="12" cy="15" r="1.2" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const externalLinkIconSvg = `
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Home", href: "/" },
|
{ label: "Home", href: "/" },
|
||||||
{ label: "Radio", href: "/radio" },
|
{ label: "Radio", href: "/radio" },
|
||||||
{ label: "Memes", href: "/memes" },
|
{ label: "Memes", href: "/memes" },
|
||||||
{ label: "Lighting", href: "/lighting", auth: true },
|
{ label: "Lighting", href: "/lighting", auth: true, icon: "padlock" },
|
||||||
{ label: "TRip", href: "/TRip", auth: true },
|
{ label: "TRip", href: "/TRip", auth: true, icon: "padlock" },
|
||||||
{ label: "Status", href: "https://status.boatson.boats", icon: ExitToApp },
|
{ label: "Status", href: "https://status.boatson.boats", icon: "external" },
|
||||||
{ label: "Git", href: "https://kode.boatson.boats", icon: ExitToApp },
|
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
||||||
// ...(isLoggedIn ? [{ label: "Logout", href: "#", onClick: "handleLogout()" }] : []), # todo
|
...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const currentPath = Astro.url.pathname;
|
const currentPath = Astro.url.pathname;
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline define:vars={{ API_URL }}>
|
||||||
toggleTheme = () => {
|
toggleTheme = () => {
|
||||||
const currentTheme = document.documentElement.getAttribute("data-theme");
|
const currentTheme = document.documentElement.getAttribute("data-theme");
|
||||||
const newTheme = currentTheme === "dark" ? "light" : "dark";
|
const newTheme = currentTheme === "dark" ? "light" : "dark";
|
||||||
document.dispatchEvent(new CustomEvent("set-theme", { detail: newTheme }));
|
document.dispatchEvent(new CustomEvent("set-theme", { detail: newTheme }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout failed", error);
|
||||||
|
} finally {
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Mobile menu toggle
|
// Mobile menu toggle
|
||||||
function initMobileMenu() {
|
function initMobileMenu() {
|
||||||
const menuBtn = document.getElementById('mobile-menu-btn');
|
const menuBtn = document.getElementById('mobile-menu-btn');
|
||||||
@@ -91,6 +118,7 @@ const currentPath = Astro.url.pathname;
|
|||||||
|
|
||||||
// Re-initialize after view transitions
|
// Re-initialize after view transitions
|
||||||
document.addEventListener('astro:page-load', initMobileMenu);
|
document.addEventListener('astro:page-load', initMobileMenu);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="w-full px-4 sm:px-6 py-4 bg-transparent sticky top-0 z-50 backdrop-blur-sm bg-white/80 dark:bg-[#121212]/80 border-b border-neutral-200/50 dark:border-neutral-800/50">
|
<nav class="w-full px-4 sm:px-6 py-4 bg-transparent sticky top-0 z-50 backdrop-blur-sm bg-white/80 dark:bg-[#121212]/80 border-b border-neutral-200/50 dark:border-neutral-800/50">
|
||||||
@@ -124,17 +152,19 @@ const currentPath = Astro.url.pathname;
|
|||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={isActive
|
class={isActive
|
||||||
? "flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900"
|
? "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900"
|
||||||
: "flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
: "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||||
}
|
}
|
||||||
target={isExternal ? "_blank" : undefined}
|
target={isExternal ? "_blank" : undefined}
|
||||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||||
|
onclick={item.onclick}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
{item.icon === ExitToApp && (
|
{item.icon === "external" && (
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
)}
|
||||||
</svg>
|
{item.icon === "padlock" && (
|
||||||
|
<span class="inline-flex" aria-hidden="true" set:html={padlockIconSvg}></span>
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -210,17 +240,19 @@ const currentPath = Astro.url.pathname;
|
|||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={isActive
|
class={isActive
|
||||||
? "flex items-center justify-between px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900"
|
? "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900"
|
||||||
: "flex items-center justify-between px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
: "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||||
}
|
}
|
||||||
target={isExternal ? "_blank" : undefined}
|
target={isExternal ? "_blank" : undefined}
|
||||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||||
|
onclick={item.onclick}
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
{item.icon === ExitToApp && (
|
{item.icon === "external" && (
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
)}
|
||||||
</svg>
|
{item.icon === "padlock" && (
|
||||||
|
<span class="inline-flex" aria-hidden="true" set:html={padlockIconSvg}></span>
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { generate, validate, parse, format } from "build-number-generator";
|
import { generate } from "build-number-generator";
|
||||||
import { MAJOR_VERSION, RELEASE_FLAG } from "../config";
|
import { MAJOR_VERSION, RELEASE_FLAG } from "../config";
|
||||||
export const buildTime = new Date().toLocaleString(undefined, {
|
export const buildTime = new Date().toLocaleString(undefined, {
|
||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
|
|||||||
Reference in New Issue
Block a user