initial commit
This commit is contained in:
		
							
								
								
									
										37
									
								
								src/components/AppLayout.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/components/AppLayout.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
// Root.jsx
 | 
			
		||||
import { React, useContext, useState, useEffect } from 'react';
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
import Alert from '@mui/joy/Alert';
 | 
			
		||||
import WarningIcon from '@mui/icons-material/Warning';
 | 
			
		||||
import {JoyUIRootIsland} from './Components.jsx';
 | 
			
		||||
import CustomToastContainer from '../components/ToastProvider.jsx';
 | 
			
		||||
import { PrimeReactProvider } from "primereact/api";
 | 
			
		||||
import { API_URL } from "../config";
 | 
			
		||||
import { Player } from "./AudioPlayer.jsx";
 | 
			
		||||
import LyricSearch from './LyricSearch.jsx';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export default function Root({child}) {
 | 
			
		||||
  window.toast = toast;
 | 
			
		||||
  window.API_URL = API_URL;
 | 
			
		||||
  const theme = document.documentElement.getAttribute("data-theme")
 | 
			
		||||
  // console.log(opts.children);
 | 
			
		||||
  return (
 | 
			
		||||
    <PrimeReactProvider>
 | 
			
		||||
    <CustomToastContainer
 | 
			
		||||
    theme={theme}
 | 
			
		||||
    newestOnTop={true}
 | 
			
		||||
    closeOnClick={true}/>
 | 
			
		||||
    <JoyUIRootIsland>
 | 
			
		||||
    <Alert
 | 
			
		||||
        startDecorator={<WarningIcon />}
 | 
			
		||||
        variant="soft"
 | 
			
		||||
        color="danger">
 | 
			
		||||
          Work in progress... bugs are to be expected.
 | 
			
		||||
        </Alert>
 | 
			
		||||
    {child == "LyricSearch" && (<LyricSearch client:only="react" />)}
 | 
			
		||||
    {child == "Player" && (<Player client:only="react"></Player>)}
 | 
			
		||||
    </JoyUIRootIsland>
 | 
			
		||||
    </PrimeReactProvider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										58
									
								
								src/components/AudioPlayer.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/components/AudioPlayer.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,58 @@
 | 
			
		||||
import {useState, React} from "react";
 | 
			
		||||
import jQuery from "jquery";
 | 
			
		||||
import Play from '@mui/icons-material/PlayArrow';
 | 
			
		||||
import Pause from '@mui/icons-material/Pause';
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
  @import "@styles/player.css";
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
export const render = false;
 | 
			
		||||
 | 
			
		||||
const PlayIcon = () => {
 | 
			
		||||
    return (
 | 
			
		||||
        <Play />
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const PauseIcon = () => {
 | 
			
		||||
    return (
 | 
			
		||||
        <Pause />
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Player() {
 | 
			
		||||
    const [isPlaying, setPlaying] = useState(false);
 | 
			
		||||
    window.isPlaying = isPlaying;
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
    <div className="c-containter">
 | 
			
		||||
        <div className="music-container">
 | 
			
		||||
            <section className="album-cover">
 | 
			
		||||
                
 | 
			
		||||
                <img src="https://api.codey.lol/radio/album_art" className="cover" alt="Cover Art" />
 | 
			
		||||
                
 | 
			
		||||
            </section>
 | 
			
		||||
            <section className="music-player">
 | 
			
		||||
                <h1 className='music-player__header'>serious.FM</h1>
 | 
			
		||||
                <h1 className="music-player__title"></h1>
 | 
			
		||||
                <h2 className="music-player__author"></h2>
 | 
			
		||||
                <h2 className="music-player__genre"></h2>
 | 
			
		||||
                <div className="music-time">
 | 
			
		||||
                    <p className="music-time__current"></p>
 | 
			
		||||
                    <p className="music-time__last"></p>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className="music-bar" id="progress">
 | 
			
		||||
                    <div id="length"></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className="music-control">
 | 
			
		||||
                    <div className="music-control__play" id="play">
 | 
			
		||||
                        {isPlaying == false && (<Play onClick={(e) => { setPlaying(!isPlaying); togglePlayback(); } } />)}
 | 
			
		||||
                        {isPlaying && (<Pause onClick={(e) => { setPlaying(!isPlaying); togglePlayback(); }} />)}
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </section>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )};
 | 
			
		||||
							
								
								
									
										47
									
								
								src/components/BaseHead.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/components/BaseHead.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
			
		||||
---
 | 
			
		||||
interface Props {
 | 
			
		||||
  title?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  image?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
import { metaData } from "../config";
 | 
			
		||||
import { SEO } from "astro-seo";
 | 
			
		||||
import { getImagePath } from "astro-opengraph-images";
 | 
			
		||||
import { JoyUIRootIsland } from "./Components"
 | 
			
		||||
const { title, description = metaData.description, image } = Astro.props;
 | 
			
		||||
 | 
			
		||||
const { url, site } = Astro;
 | 
			
		||||
const openGraphImageUrl = getImagePath({ url, site });
 | 
			
		||||
 | 
			
		||||
// If the image is not provided, use the default image
 | 
			
		||||
const openGraphImage = image
 | 
			
		||||
  ? new URL(image, url.href).href
 | 
			
		||||
  : openGraphImageUrl;
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<SEO
 | 
			
		||||
  title={title}
 | 
			
		||||
  titleTemplate=`%s | ${metaData.title}`
 | 
			
		||||
  titleDefault={metaData.title}
 | 
			
		||||
  description={description}
 | 
			
		||||
  charset="UTF-8"
 | 
			
		||||
  openGraph={{
 | 
			
		||||
    basic: {
 | 
			
		||||
      title: title || metaData.title,
 | 
			
		||||
      type: "website",
 | 
			
		||||
      image: openGraphImageUrl,
 | 
			
		||||
      url: url,
 | 
			
		||||
    },
 | 
			
		||||
    optional: {
 | 
			
		||||
      description,
 | 
			
		||||
      siteName: "codey.lol",
 | 
			
		||||
      locale: "en_US",
 | 
			
		||||
    },
 | 
			
		||||
  }}
 | 
			
		||||
  extend={{
 | 
			
		||||
    // extending the default link tags
 | 
			
		||||
    link: [{ rel: "icon", href: "https://codey.lol/images/favicon.png" }],
 | 
			
		||||
  }}
 | 
			
		||||
/>
 | 
			
		||||
							
								
								
									
										28
									
								
								src/components/Components.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/components/Components.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
import React, {
 | 
			
		||||
  useRef,
 | 
			
		||||
  forwardRef,
 | 
			
		||||
  useImperativeHandle,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useState,
 | 
			
		||||
} from "react";
 | 
			
		||||
import { CssVarsProvider, CssBaseline } from "@mui/joy";
 | 
			
		||||
import { CacheProvider } from "@emotion/react";
 | 
			
		||||
import createCache from "@emotion/cache";
 | 
			
		||||
import { default as $ } from "jquery";
 | 
			
		||||
 | 
			
		||||
export function JoyUIRootIsland({ children }) {
 | 
			
		||||
  const cache = React.useRef();
 | 
			
		||||
  if (!cache.current) {
 | 
			
		||||
    cache.current = createCache({ key: "joy" });
 | 
			
		||||
  }
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    const current = cache.current;
 | 
			
		||||
    return () => current.sheet.flush();
 | 
			
		||||
  }, []);
 | 
			
		||||
  return (
 | 
			
		||||
    <CacheProvider value={cache.current}>
 | 
			
		||||
      <CssVarsProvider>{children}</CssVarsProvider>
 | 
			
		||||
    </CacheProvider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										22
									
								
								src/components/Footer.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/components/Footer.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
---
 | 
			
		||||
import { metaData } from "../config";
 | 
			
		||||
import { Icon } from "astro-icon/components";
 | 
			
		||||
 | 
			
		||||
const YEAR = new Date().getFullYear();
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<small class="block lg:mt-24 mt-16 text-[#1C1C1C] dark:text-[#D4D4D4] footer-text">
 | 
			
		||||
  <time>© {YEAR}</time>{" "}
 | 
			
		||||
    {metaData.owner}
 | 
			
		||||
  </a>
 | 
			
		||||
</small>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
  @media screen and (max-width: 480px) {
 | 
			
		||||
    article {
 | 
			
		||||
      padding-top: 2rem;
 | 
			
		||||
      padding-bottom: 4rem;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										52
									
								
								src/components/FormattedDate.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/components/FormattedDate.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,52 @@
 | 
			
		||||
---
 | 
			
		||||
interface Props {
 | 
			
		||||
  date: Date | string;
 | 
			
		||||
  includeRelative?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { date, includeRelative = false } = Astro.props;
 | 
			
		||||
 | 
			
		||||
const formatDate = (date: Date | string, includeRelative: boolean): string => {
 | 
			
		||||
  let currentDate = new Date();
 | 
			
		||||
  let targetDate = date instanceof Date ? date : new Date(date);
 | 
			
		||||
 | 
			
		||||
  if (isNaN(targetDate.getTime())) {
 | 
			
		||||
    console.error("Invalid date:", date);
 | 
			
		||||
    return "Invalid Date";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let yearsAgo = currentDate.getFullYear() - targetDate.getFullYear();
 | 
			
		||||
  let monthsAgo = currentDate.getMonth() - targetDate.getMonth();
 | 
			
		||||
  let daysAgo = currentDate.getDate() - targetDate.getDate();
 | 
			
		||||
 | 
			
		||||
  let formattedDate = "";
 | 
			
		||||
 | 
			
		||||
  if (yearsAgo > 0) {
 | 
			
		||||
    formattedDate = `${yearsAgo}y ago`;
 | 
			
		||||
  } else if (monthsAgo > 0) {
 | 
			
		||||
    formattedDate = `${monthsAgo}mo ago`;
 | 
			
		||||
  } else if (daysAgo > 0) {
 | 
			
		||||
    formattedDate = `${daysAgo}d ago`;
 | 
			
		||||
  } else {
 | 
			
		||||
    formattedDate = "Today";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let fullDate = targetDate.toLocaleString("en-us", {
 | 
			
		||||
    month: "short",
 | 
			
		||||
    day: "numeric",
 | 
			
		||||
    year: "numeric",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!includeRelative) {
 | 
			
		||||
    return fullDate;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return `${fullDate} (${formattedDate})`;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const formattedDateString = formatDate(date, includeRelative);
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<time datetime={new Date(date).toISOString()}>
 | 
			
		||||
  {formattedDateString}
 | 
			
		||||
</time>
 | 
			
		||||
							
								
								
									
										280
									
								
								src/components/LyricSearch.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								src/components/LyricSearch.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,280 @@
 | 
			
		||||
import { CircularProgress } from "@mui/joy";
 | 
			
		||||
import React, {
 | 
			
		||||
  forwardRef,
 | 
			
		||||
  useImperativeHandle,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useRef,
 | 
			
		||||
  useState,
 | 
			
		||||
} from "react";
 | 
			
		||||
import { default as $ } from "jquery";
 | 
			
		||||
import Alert from '@mui/joy/Alert';
 | 
			
		||||
import Box from '@mui/joy/Box';
 | 
			
		||||
import Button from "@mui/joy/Button";
 | 
			
		||||
import Checkbox from "@mui/joy/Checkbox";
 | 
			
		||||
import jQuery from "jquery";
 | 
			
		||||
import { AutoComplete } from 'primereact/autocomplete';
 | 
			
		||||
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";
 | 
			
		||||
  let newTheme = e.detail;
 | 
			
		||||
  if (newTheme !== "light") {
 | 
			
		||||
    removedClass = "lyrics-card-light";
 | 
			
		||||
  }
 | 
			
		||||
  $(box).removeClass(removedClass)
 | 
			
		||||
  $(box).addClass(`lyrics-card-${newTheme}`);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default function LyricSearch() {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="lyric-search">
 | 
			
		||||
    <h2 className="title">
 | 
			
		||||
    <span>Lyric Search</span>
 | 
			
		||||
    </h2>
 | 
			
		||||
    <div className="card-text my-4">
 | 
			
		||||
    <label>Search:</label>
 | 
			
		||||
    <LyricSearchInputField
 | 
			
		||||
              id="lyric-search-input"
 | 
			
		||||
              placeholder="Artist - Song" />
 | 
			
		||||
    <br />
 | 
			
		||||
    Exclude:<br />
 | 
			
		||||
    <div id="exclude-checkboxes">
 | 
			
		||||
    <UICheckbox id="excl-Genius" label="Genius" />
 | 
			
		||||
    <UICheckbox id="excl-LRCLib" label="LRCLib" />
 | 
			
		||||
    <UICheckbox id="excl-Cache" label="Cache" />
 | 
			
		||||
    </div>
 | 
			
		||||
    <div id="spinner" className="hidden">
 | 
			
		||||
    <CircularProgress
 | 
			
		||||
    variant="plain"
 | 
			
		||||
    color="primary"
 | 
			
		||||
    size="md"/></div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <LyricResultBox/>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function LyricSearchInputField(opts = {}) {
 | 
			
		||||
  const [value, setValue] = useState("");
 | 
			
		||||
  const [suggestions, setSuggestions] = useState([]);
 | 
			
		||||
  const [showAlert, setShowAlert] = useState(false);
 | 
			
		||||
  const autoCompleteRef = useRef(null);
 | 
			
		||||
  
 | 
			
		||||
  var search_toast = null;
 | 
			
		||||
  var ret_artist = null;
 | 
			
		||||
  var ret_song = null;
 | 
			
		||||
  var ret_lyrics = null;
 | 
			
		||||
  var start_time = null;
 | 
			
		||||
  var end_time = null;
 | 
			
		||||
  
 | 
			
		||||
  useEffect(() => {}, []);
 | 
			
		||||
  
 | 
			
		||||
  async function handleSearch() {
 | 
			
		||||
    if (autoCompleteRef.current) {
 | 
			
		||||
      autoCompleteRef.current.hide();
 | 
			
		||||
    }
 | 
			
		||||
    let validSearch = (value.trim() && (value.split(" - ").length > 1));
 | 
			
		||||
    let box = document.querySelector("[class*='lyrics-card-']")
 | 
			
		||||
    let spinner = $('#spinner');
 | 
			
		||||
    let excluded_sources = [];
 | 
			
		||||
    
 | 
			
		||||
    $("#exclude-checkboxes").find("input:checkbox").each(function () {
 | 
			
		||||
      if (this.checked) {
 | 
			
		||||
        let src = this.id.replace("excl-", "").toLowerCase();
 | 
			
		||||
        excluded_sources.push(src);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    if (validSearch) { 
 | 
			
		||||
      // define artist, song + additional checks
 | 
			
		||||
      let a_s_split = value.split(" - ", 2)
 | 
			
		||||
      var [search_artist, search_song] = a_s_split;
 | 
			
		||||
      search_artist = search_artist.trim();
 | 
			
		||||
      search_song = search_song.trim();
 | 
			
		||||
      if (! search_artist || ! search_song) {
 | 
			
		||||
        validSearch = false; // artist and song could not be derived
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (!validSearch) {
 | 
			
		||||
      setShowAlert(true);
 | 
			
		||||
      setTimeout(() => { setShowAlert(false); }, 5000);
 | 
			
		||||
      $("#alert").removeClass("hidden");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (!$("#alert").hasClass("hidden")) {
 | 
			
		||||
      setShowAlert(false);
 | 
			
		||||
      $("#alert").addClass("hidden");
 | 
			
		||||
    }
 | 
			
		||||
    $('#spinner').removeClass("hidden");
 | 
			
		||||
    $(box).addClass("hidden");
 | 
			
		||||
    //setTimeout(() => { $("#spinner").addClass("hidden"); alert('Not yet implemented.')}, 1000);
 | 
			
		||||
    search_toast = toast.info("Searching...");
 | 
			
		||||
    start_time = new Date().getTime()
 | 
			
		||||
    $.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: excluded_sources,
 | 
			
		||||
        src: 'Web',
 | 
			
		||||
        extra: true,
 | 
			
		||||
      })
 | 
			
		||||
    }).done((data, txtStatus, xhr) => {
 | 
			
		||||
      if (data.err || !data.lyrics) {
 | 
			
		||||
        $(spinner).addClass("hidden");
 | 
			
		||||
        return toast.update(search_toast, {
 | 
			
		||||
          type: "",
 | 
			
		||||
          render: `🙁 ${data.errorText}`,
 | 
			
		||||
          style: { backgroundColor: "rgba(255, 0, 0, 0.5)", color: 'inherit' },
 | 
			
		||||
          hideProgressBar: true,
 | 
			
		||||
          autoClose: 5000,
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      end_time = new Date().getTime();
 | 
			
		||||
      let duration = (end_time - start_time) / 1000;
 | 
			
		||||
      ret_artist = data.artist;
 | 
			
		||||
      ret_song = data.song;
 | 
			
		||||
      ret_lyrics = data.lyrics;
 | 
			
		||||
      $(box).removeClass("hidden");
 | 
			
		||||
      $(spinner).addClass("hidden");
 | 
			
		||||
      $(box).html(`<span id='lyrics-info'>${ret_artist} - ${ret_song}</span>${ret_lyrics}`);
 | 
			
		||||
      toast.update(search_toast, {
 | 
			
		||||
        type: "",
 | 
			
		||||
        style: { backgroundColor: "rgba(46, 186, 106, 1)", color: 'inherit'  },
 | 
			
		||||
        render: `🦄 Found! (Took ${duration}s)`,
 | 
			
		||||
        autoClose: 2000,
 | 
			
		||||
        hideProgressBar: true
 | 
			
		||||
      });
 | 
			
		||||
    }).fail((jqXHR, textStatus, error) => {
 | 
			
		||||
      $(spinner).addClass("hidden");
 | 
			
		||||
      return toast.update(search_toast, {
 | 
			
		||||
        render: `😕 Failed to reach search endpoint (${jqXHR.status})`,
 | 
			
		||||
        style: { backgroundColor: "rgba(255, 0, 0, 0.5)", color: 'inherit' },
 | 
			
		||||
        hideProgressBar: true,
 | 
			
		||||
        autoClose: 5000,
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  const handleKeyDown = (e) => {
 | 
			
		||||
    if (e.key !== "Enter") return;
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    handleSearch();
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  const typeahead_search = (event) => {
 | 
			
		||||
    let query = event.query;
 | 
			
		||||
    $.ajax({
 | 
			
		||||
      url: API_URL+'/typeahead/lyrics',
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      contentType: 'application/json; charset=utf-8',
 | 
			
		||||
      data: JSON.stringify({
 | 
			
		||||
        query: query
 | 
			
		||||
      }),
 | 
			
		||||
      dataType: 'json',
 | 
			
		||||
      success: function (json) {
 | 
			
		||||
        return setSuggestions(json);
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    // Fetch data from your API using the event.query (user's input)
 | 
			
		||||
    // axios.get(`your-api-endpoint?query=${event.query}`)
 | 
			
		||||
    //     .then(response => {
 | 
			
		||||
      //         setSuggestions(response.data); // Update suggestions state with the fetched data
 | 
			
		||||
    //     })
 | 
			
		||||
    //     .catch(error => {
 | 
			
		||||
      //         console.error('Error fetching suggestions:', error);
 | 
			
		||||
    //     });
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
    <div id="alert">
 | 
			
		||||
    {showAlert && (
 | 
			
		||||
      <Alert
 | 
			
		||||
      color="danger"
 | 
			
		||||
      variant="solid"
 | 
			
		||||
      onClose={() => setShowAlert(false)}
 | 
			
		||||
      >
 | 
			
		||||
      You must specify both an artist and song to search.
 | 
			
		||||
      <br />
 | 
			
		||||
      Format: Artist - Song
 | 
			
		||||
      </Alert>
 | 
			
		||||
    )}
 | 
			
		||||
    </div>
 | 
			
		||||
    <AutoComplete
 | 
			
		||||
    theme="nano"
 | 
			
		||||
    size="40"
 | 
			
		||||
    scrollHeight="200px"
 | 
			
		||||
    autoFocus={true}
 | 
			
		||||
    virtualScrollerOptions={false}
 | 
			
		||||
    value={value}
 | 
			
		||||
    id={opts.id}
 | 
			
		||||
    ref={autoCompleteRef}
 | 
			
		||||
    suggestions={suggestions}
 | 
			
		||||
    completeMethod={typeahead_search}
 | 
			
		||||
    placeholder={opts.placeholder}
 | 
			
		||||
    onChange={(e) => { setValue(e.target.value)  }}
 | 
			
		||||
    onKeyDown={handleKeyDown}
 | 
			
		||||
    />
 | 
			
		||||
    <Button id="lyric-search-btn" onClick={handleSearch} className={"btn"}>
 | 
			
		||||
    Search
 | 
			
		||||
    </Button>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const UICheckbox = forwardRef(function UICheckbox(opts = {}, ref) {
 | 
			
		||||
  const [checked, setChecked] = useState(false);
 | 
			
		||||
  const [showAlert, setShowAlert] = useState(false);
 | 
			
		||||
  let valid_exclusions = true;
 | 
			
		||||
  useImperativeHandle(ref, () => ({
 | 
			
		||||
    setChecked: (val) => setChecked(val),
 | 
			
		||||
    checked, // (optional) expose value for reading too
 | 
			
		||||
  }));
 | 
			
		||||
  
 | 
			
		||||
  const verifyExclusions = (e) => {
 | 
			
		||||
    let exclude_error = false;
 | 
			
		||||
    if (($("#exclude-checkboxes").find("input:checkbox").filter(":checked").length == 3)){
 | 
			
		||||
      $("#exclude-checkboxes").find("input:checkbox").each(function () {
 | 
			
		||||
        exclude_error = true;
 | 
			
		||||
        this.click();
 | 
			
		||||
      });
 | 
			
		||||
      if (exclude_error) {
 | 
			
		||||
        toast.error("All sources were excluded; exclusions have been reset.",
 | 
			
		||||
          { style: { backgroundColor: "rgba(255, 0, 0, 0.5)", color: 'inherit' } },
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return true;
 | 
			
		||||
  };
 | 
			
		||||
  return ( 
 | 
			
		||||
    <div> 
 | 
			
		||||
    <Checkbox
 | 
			
		||||
    id={opts.id}
 | 
			
		||||
    key={opts.label}
 | 
			
		||||
    checked={checked}
 | 
			
		||||
    label={opts.label}
 | 
			
		||||
    style={{ color: "inherit" }}
 | 
			
		||||
    onChange={(e) => {
 | 
			
		||||
      setChecked(e.target.checked);
 | 
			
		||||
      verifyExclusions();
 | 
			
		||||
    }}
 | 
			
		||||
    />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export function LyricResultBox(opts={}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box className={`lyrics-card lyrics-card-${theme} hidden`} sx={{p: 2 }}></Box>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										76
									
								
								src/components/Nav.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								src/components/Nav.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,76 @@
 | 
			
		||||
---
 | 
			
		||||
import { metaData } from "../config";
 | 
			
		||||
import { Icon } from "astro-icon/components";
 | 
			
		||||
import ExitToApp from '@mui/icons-material/ExitToApp';
 | 
			
		||||
import HorizontalRuleIcon from '@mui/icons-material/HorizontalRule';
 | 
			
		||||
 | 
			
		||||
const navItems = {
 | 
			
		||||
  "/": { name: "Home", className: "", icon: null },
 | 
			
		||||
  "": { name: "", className: "", icon: HorizontalRuleIcon },
 | 
			
		||||
  "/radio": { name: "Radio", className: "", icon: null },
 | 
			
		||||
  "": { name: "", className: "", icon: HorizontalRuleIcon },
 | 
			
		||||
  "https://status.boatson.boats": { name: "Status", className: "", icon: ExitToApp }
 | 
			
		||||
};
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<nav class="lg:mb-16 mb-12 py-5">
 | 
			
		||||
  <div class="flex flex-col md:flex-row md:items-center justify-between">
 | 
			
		||||
    <div class="flex items-center">
 | 
			
		||||
      <a href="/" class="text-3xl font-semibold header-text">
 | 
			
		||||
        {metaData.title}
 | 
			
		||||
      </a>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="flex flex-row gap-4 mt-6 md:mt-0 md:ml-auto items-center">
 | 
			
		||||
      {
 | 
			
		||||
        Object.entries(navItems).map(([path, { name, className, icon }]) => 
 | 
			
		||||
        {
 | 
			
		||||
          let isVisualSeparator = icon === HorizontalRuleIcon
 | 
			
		||||
 | 
			
		||||
          if (isVisualSeparator) {
 | 
			
		||||
            return (
 | 
			
		||||
              <HorizontalRuleIcon client:load
 | 
			
		||||
                                                          size="sm"
 | 
			
		||||
                                                          className="hr"
 | 
			
		||||
                                                          sx={{
 | 
			
		||||
                                                            color: 'inherit',
 | 
			
		||||
                                                          }} />
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
        return (
 | 
			
		||||
          <a
 | 
			
		||||
            href={path}
 | 
			
		||||
            class={`transition-all hover:text-neutral-800 dark:hover:text-neutral-200 flex align-middle relative`}>
 | 
			
		||||
            {name}
 | 
			
		||||
          </a>
 | 
			
		||||
        )})
 | 
			
		||||
      }
 | 
			
		||||
      <button
 | 
			
		||||
        id="theme-toggle"
 | 
			
		||||
        aria-label="Toggle theme"
 | 
			
		||||
        class="flex items-center justify-center transition-opacity duration-300 hover:opacity-90">
 | 
			
		||||
        <Icon
 | 
			
		||||
          name="fa6-solid:circle-half-stroke"
 | 
			
		||||
          class="h-[14px] w-[14px] text-[#1c1c1c] dark:text-[#D4D4D4]"
 | 
			
		||||
        />
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</nav>
 | 
			
		||||
 | 
			
		||||
<script is:inline>
 | 
			
		||||
  function setTheme(theme) {
 | 
			
		||||
    document.dispatchEvent(new CustomEvent("set-theme", { detail: theme }));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function toggleTheme() {
 | 
			
		||||
    const currentTheme = document.documentElement.getAttribute("data-theme");
 | 
			
		||||
    const newTheme = currentTheme === "dark" ? "light" : "dark";
 | 
			
		||||
    setTheme(newTheme);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  document.addEventListener("astro:page-load", () => {
 | 
			
		||||
    document
 | 
			
		||||
      .getElementById("theme-toggle")
 | 
			
		||||
      .addEventListener("click", toggleTheme);
 | 
			
		||||
  });
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										21
									
								
								src/components/ToastProvider.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/components/ToastProvider.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { ToastContainer } from 'react-toastify';
 | 
			
		||||
import 'react-toastify/dist/ReactToastify.css';
 | 
			
		||||
 | 
			
		||||
const CustomToastContainer = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <ToastContainer
 | 
			
		||||
      position="top-right"
 | 
			
		||||
      autoClose={5000}
 | 
			
		||||
      hideProgressBar={false}
 | 
			
		||||
      newestOnTop={false}
 | 
			
		||||
      closeOnClick
 | 
			
		||||
      rtl={false}
 | 
			
		||||
      pauseOnFocusLoss
 | 
			
		||||
      draggable
 | 
			
		||||
      pauseOnHover
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default CustomToastContainer;
 | 
			
		||||
							
								
								
									
										15
									
								
								src/components/mdx/Callout.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/components/mdx/Callout.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
---
 | 
			
		||||
interface Props {
 | 
			
		||||
  emoji: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { emoji } = Astro.props;
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<div
 | 
			
		||||
  class="px-4 py-3 bg-[#F7F7F7] dark:bg-[#181818] rounded p-1 text-sm flex items-center text-neutral-900 dark:text-neutral-100 mb-8">
 | 
			
		||||
  <div class="flex items-center w-4 mr-4">{emoji}</div>
 | 
			
		||||
  <div class="w-full callout leading-relaxed">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										12
									
								
								src/components/mdx/Caption.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/components/mdx/Caption.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
---
 | 
			
		||||
import { Balancer } from "react-wrap-balancer";
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<span
 | 
			
		||||
  class="block w-full text-xs my-3 font-mono text-gray-500 text-center leading-normal">
 | 
			
		||||
  <Balancer>
 | 
			
		||||
    <span class="">
 | 
			
		||||
      <slot />
 | 
			
		||||
    </span>
 | 
			
		||||
  </Balancer>
 | 
			
		||||
</span>
 | 
			
		||||
							
								
								
									
										54
									
								
								src/components/mdx/ImageGrid.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/components/mdx/ImageGrid.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
			
		||||
---
 | 
			
		||||
import { Image } from "astro:assets";
 | 
			
		||||
 | 
			
		||||
interface ImageGridProps {
 | 
			
		||||
  images: {
 | 
			
		||||
    src: string;
 | 
			
		||||
    alt: string;
 | 
			
		||||
    href?: string;
 | 
			
		||||
  }[];
 | 
			
		||||
  columns?: 2 | 3 | 4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { images, columns = 3 } = Astro.props as ImageGridProps;
 | 
			
		||||
 | 
			
		||||
const gridClass = {
 | 
			
		||||
  2: "grid-cols-2 sm:grid-cols-2",
 | 
			
		||||
  3: "grid-cols-2 sm:grid-cols-3",
 | 
			
		||||
  4: "grid-cols-2 sm:grid-cols-4",
 | 
			
		||||
}[columns];
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<section>
 | 
			
		||||
  <div class={`grid ${gridClass} gap-4 my-8`}>
 | 
			
		||||
    {
 | 
			
		||||
      images.map((image) => (
 | 
			
		||||
        <div class="relative aspect-square">
 | 
			
		||||
          {image.href ? (
 | 
			
		||||
            <a
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
              href={image.href}
 | 
			
		||||
              class="block w-full h-full">
 | 
			
		||||
              <Image
 | 
			
		||||
                alt={image.alt}
 | 
			
		||||
                src={image.src}
 | 
			
		||||
                width={500}
 | 
			
		||||
                height={500}
 | 
			
		||||
                class="rounded-lg object-cover w-full h-full"
 | 
			
		||||
              />
 | 
			
		||||
            </a>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <Image
 | 
			
		||||
              alt={image.alt}
 | 
			
		||||
              src={image.src}
 | 
			
		||||
              width={500}
 | 
			
		||||
              height={500}
 | 
			
		||||
              class="rounded-lg object-cover w-full h-full"
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      ))
 | 
			
		||||
    }
 | 
			
		||||
  </div>
 | 
			
		||||
</section>
 | 
			
		||||
							
								
								
									
										31
									
								
								src/components/mdx/Table.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/components/mdx/Table.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,31 @@
 | 
			
		||||
---
 | 
			
		||||
interface TableData {
 | 
			
		||||
  headers: string[];
 | 
			
		||||
  rows: string[][];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  data: TableData;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { data } = Astro.props;
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<table>
 | 
			
		||||
  <thead>
 | 
			
		||||
    <tr class="text-left">
 | 
			
		||||
      {data.headers.map((header) => <th>{header}</th>)}
 | 
			
		||||
    </tr>
 | 
			
		||||
  </thead>
 | 
			
		||||
  <tbody>
 | 
			
		||||
    {
 | 
			
		||||
      data.rows.map((row) => (
 | 
			
		||||
        <tr>
 | 
			
		||||
          {row.map((cell) => (
 | 
			
		||||
            <td>{cell}</td>
 | 
			
		||||
          ))}
 | 
			
		||||
        </tr>
 | 
			
		||||
      ))
 | 
			
		||||
    }
 | 
			
		||||
  </tbody>
 | 
			
		||||
</table>
 | 
			
		||||
							
								
								
									
										36
									
								
								src/components/mdx/Tweet.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/components/mdx/Tweet.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
---
 | 
			
		||||
import { getTweet } from "react-tweet/api";
 | 
			
		||||
import { EmbeddedTweet, TweetNotFound, type TweetProps } from "react-tweet";
 | 
			
		||||
import "./tweet.css";
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  id: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { id } = Astro.props;
 | 
			
		||||
 | 
			
		||||
let tweet;
 | 
			
		||||
let error;
 | 
			
		||||
 | 
			
		||||
if (id) {
 | 
			
		||||
  try {
 | 
			
		||||
    tweet = await getTweet(id);
 | 
			
		||||
  } catch (err) {
 | 
			
		||||
    console.error(err);
 | 
			
		||||
    error = err;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const TweetContent = () => {
 | 
			
		||||
  if (!tweet) {
 | 
			
		||||
    return <TweetNotFound error={error} />;
 | 
			
		||||
  }
 | 
			
		||||
  return <EmbeddedTweet tweet={tweet} />;
 | 
			
		||||
};
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<div class="tweet my-6">
 | 
			
		||||
  <div class="flex justify-center">
 | 
			
		||||
    <TweetContent />
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										20
									
								
								src/components/mdx/YouTube.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/components/mdx/YouTube.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
---
 | 
			
		||||
import YT from "react-youtube";
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  videoId: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { videoId } = Astro.props;
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<div class="relative w-full h-0 pb-[56.25%] my-6">
 | 
			
		||||
  <YT
 | 
			
		||||
    opts={{
 | 
			
		||||
      height: "100%",
 | 
			
		||||
      width: "100%",
 | 
			
		||||
    }}
 | 
			
		||||
    videoId={videoId}
 | 
			
		||||
    class="absolute top-0 left-0 w-full h-full"
 | 
			
		||||
  />
 | 
			
		||||
</div>
 | 
			
		||||
		Reference in New Issue
	
	Block a user