navbar changes, radio/webplayer rewrite + additional stations
This commit is contained in:
		@@ -8,7 +8,7 @@ import Alert from '@mui/joy/Alert';
 | 
			
		||||
import WarningIcon from '@mui/icons-material/Warning';
 | 
			
		||||
import CustomToastContainer from '../components/ToastProvider.jsx';
 | 
			
		||||
import LyricSearch from './LyricSearch.jsx';
 | 
			
		||||
import 'primereact/resources/themes/bootstrap4-light-blue/theme.css'; // TEMP
 | 
			
		||||
import 'primereact/resources/themes/bootstrap4-light-blue/theme.css';
 | 
			
		||||
import 'primereact/resources/primereact.min.css';
 | 
			
		||||
 | 
			
		||||
export default function Root({child}) {
 | 
			
		||||
@@ -24,6 +24,7 @@ export default function Root({child}) {
 | 
			
		||||
    closeOnClick={true}/>
 | 
			
		||||
    <JoyUIRootIsland>
 | 
			
		||||
    <Alert
 | 
			
		||||
        className="alert"
 | 
			
		||||
        startDecorator={<WarningIcon />}
 | 
			
		||||
        variant="soft"
 | 
			
		||||
        color="danger">
 | 
			
		||||
 
 | 
			
		||||
@@ -1,57 +1,243 @@
 | 
			
		||||
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>
 | 
			
		||||
import { useState, useEffect, useRef } from "react";
 | 
			
		||||
import { Howl, Howler } from "howler";
 | 
			
		||||
import Play from "@mui/icons-material/PlayArrow";
 | 
			
		||||
import Pause from "@mui/icons-material/Pause";
 | 
			
		||||
import "@styles/player.css";
 | 
			
		||||
 | 
			
		||||
export const render = false;
 | 
			
		||||
const API_URL = "https://api.codey.lol";
 | 
			
		||||
Howler.html5PoolSize = 32;
 | 
			
		||||
 | 
			
		||||
const PlayIcon = () => {
 | 
			
		||||
    return (
 | 
			
		||||
        <Play />
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
const STATIONS = {
 | 
			
		||||
  main: { label: "Main", streamPath: "/sfm.ogg" },
 | 
			
		||||
  rock: { label: "Rock", streamPath: "/rock.ogg" },
 | 
			
		||||
  rap: { label: "Rap", streamPath: "/rap.ogg" },
 | 
			
		||||
  electronic: { label: "Electronic", streamPath: "/electronic.ogg" },
 | 
			
		||||
  classical: { label: "Classical", streamPath: "/classical.ogg" },
 | 
			
		||||
  pop: { label: "Pop", streamPath: "/pop.ogg" },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const PauseIcon = () => {
 | 
			
		||||
    return (
 | 
			
		||||
        <Pause />
 | 
			
		||||
    )
 | 
			
		||||
// Global interval tracking (required for Astro + client:only)
 | 
			
		||||
let activeInterval = null;
 | 
			
		||||
let currentStationForInterval = null;
 | 
			
		||||
 | 
			
		||||
function clearGlobalMetadataInterval() {
 | 
			
		||||
  if (activeInterval) {
 | 
			
		||||
    clearInterval(activeInterval);
 | 
			
		||||
    activeInterval = null;
 | 
			
		||||
    currentStationForInterval = null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Player() {
 | 
			
		||||
    const [isPlaying, setPlaying] = useState(false);
 | 
			
		||||
    window.isPlaying = isPlaying;
 | 
			
		||||
  const [activeStation, setActiveStation] = useState("main");
 | 
			
		||||
  const [isPlaying, setIsPlaying] = useState(false);
 | 
			
		||||
  const [trackTitle, setTrackTitle] = useState("");
 | 
			
		||||
  const [trackArtist, setTrackArtist] = useState("");
 | 
			
		||||
  const [trackGenre, setTrackGenre] = useState("");
 | 
			
		||||
  const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
 | 
			
		||||
  const [elapsed, setElapsed] = useState(0);
 | 
			
		||||
  const [duration, setDuration] = useState(0);
 | 
			
		||||
 | 
			
		||||
  const soundRef = useRef(null);
 | 
			
		||||
  const uuidRef = useRef(null);
 | 
			
		||||
  const lastStationData = useRef(null);
 | 
			
		||||
 | 
			
		||||
  const formatTime = (seconds) => {
 | 
			
		||||
    const m = String(Math.floor(seconds / 60)).padStart(2, "0");
 | 
			
		||||
    const s = String(Math.floor(seconds % 60)).padStart(2, "0");
 | 
			
		||||
    return `${m}:${s}`;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const progress = duration > 0 ? (elapsed / duration) * 100 : 0;
 | 
			
		||||
 | 
			
		||||
  // Create Howl instance on activeStation change
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (soundRef.current) {
 | 
			
		||||
      soundRef.current.unload();
 | 
			
		||||
      soundRef.current = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const streamUrl = "https://stream.codey.lol" + STATIONS[activeStation].streamPath;
 | 
			
		||||
 | 
			
		||||
    const howl = new Howl({
 | 
			
		||||
      src: [streamUrl],
 | 
			
		||||
      html5: true,
 | 
			
		||||
      onend: () => howl.play(),
 | 
			
		||||
      onplay: () => setIsPlaying(true),
 | 
			
		||||
      onpause: () => setIsPlaying(false),
 | 
			
		||||
      onstop: () => setIsPlaying(false),
 | 
			
		||||
      onloaderror: (id, err) => console.error("Load error", err),
 | 
			
		||||
      onplayerror: (id, err) => {
 | 
			
		||||
        console.error("Play error", err);
 | 
			
		||||
        setTimeout(() => howl.play(), 1000);
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    soundRef.current = howl;
 | 
			
		||||
    uuidRef.current = null;
 | 
			
		||||
    lastStationData.current = null;
 | 
			
		||||
 | 
			
		||||
    // Autoplay if previously playing
 | 
			
		||||
    if (isPlaying) {
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        if (soundRef.current) {
 | 
			
		||||
          soundRef.current.play();
 | 
			
		||||
        }
 | 
			
		||||
      }, 100);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      howl.stop();
 | 
			
		||||
      howl.unload();
 | 
			
		||||
    };
 | 
			
		||||
  }, [activeStation]);
 | 
			
		||||
 | 
			
		||||
const togglePlayback = () => {
 | 
			
		||||
  if (isPlaying) {
 | 
			
		||||
    if (soundRef.current) {
 | 
			
		||||
      soundRef.current.pause();
 | 
			
		||||
    }
 | 
			
		||||
    setIsPlaying(false);
 | 
			
		||||
  } else {
 | 
			
		||||
    // If a previous Howl exists, unload it
 | 
			
		||||
    if (soundRef.current) {
 | 
			
		||||
      soundRef.current.stop();
 | 
			
		||||
      soundRef.current.unload();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const streamUrl =
 | 
			
		||||
      "https://stream.codey.lol" +
 | 
			
		||||
      STATIONS[activeStation].streamPath +
 | 
			
		||||
      `?t=${Date.now()}`; // Cache-busting param
 | 
			
		||||
 | 
			
		||||
    const newHowl = new Howl({
 | 
			
		||||
      src: [streamUrl],
 | 
			
		||||
      html5: true,
 | 
			
		||||
      onend: () => newHowl.play(),
 | 
			
		||||
      onplay: () => {
 | 
			
		||||
        setIsPlaying(true);
 | 
			
		||||
      },
 | 
			
		||||
      onpause: () => setIsPlaying(false),
 | 
			
		||||
      onstop: () => setIsPlaying(false),
 | 
			
		||||
      onloaderror: (_, err) => console.error("Load error", err),
 | 
			
		||||
      onplayerror: (_, err) => {
 | 
			
		||||
        console.error("Play error", err);
 | 
			
		||||
        setTimeout(() => newHowl.play(), 1000);
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    soundRef.current = newHowl;
 | 
			
		||||
    newHowl.play();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  // Metadata fetcher: global-safe
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    clearGlobalMetadataInterval();
 | 
			
		||||
 | 
			
		||||
    currentStationForInterval = activeStation;
 | 
			
		||||
 | 
			
		||||
    const fetchTrackData = async () => {
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, {
 | 
			
		||||
          method: "POST",
 | 
			
		||||
        });
 | 
			
		||||
        const data = await response.json();
 | 
			
		||||
 | 
			
		||||
        // Ignore stale interval calls
 | 
			
		||||
        if (currentStationForInterval !== activeStation) return;
 | 
			
		||||
 | 
			
		||||
        if (data.artist === "N/A" && data.song === "N/A") {
 | 
			
		||||
          if (lastStationData.current !== "offline") {
 | 
			
		||||
            setTrackTitle("Offline");
 | 
			
		||||
            setTrackArtist("");
 | 
			
		||||
            setTrackGenre("");
 | 
			
		||||
            setCoverArt("/images/radio_art_default.jpg");
 | 
			
		||||
            setElapsed(0);
 | 
			
		||||
            setDuration(0);
 | 
			
		||||
            lastStationData.current = "offline";
 | 
			
		||||
          }
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (data.uuid === uuidRef.current) {
 | 
			
		||||
          if (lastStationData.current === data.uuid) {
 | 
			
		||||
            setElapsed(data.elapsed);
 | 
			
		||||
            setDuration(data.duration);
 | 
			
		||||
          }
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        uuidRef.current = data.uuid;
 | 
			
		||||
        lastStationData.current = data.uuid;
 | 
			
		||||
        setTrackTitle(data.song);
 | 
			
		||||
        setTrackArtist(data.artist);
 | 
			
		||||
        setTrackGenre(data.genre !== "N/A" ? data.genre : "");
 | 
			
		||||
        setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`);
 | 
			
		||||
        setElapsed(data.elapsed);
 | 
			
		||||
        setDuration(data.duration);
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error("Failed to fetch track data:", error);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    fetchTrackData();
 | 
			
		||||
    activeInterval = setInterval(fetchTrackData, 1000);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      clearGlobalMetadataInterval();
 | 
			
		||||
    };
 | 
			
		||||
  }, [activeStation]);
 | 
			
		||||
 | 
			
		||||
  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 className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative">
 | 
			
		||||
        {Object.entries(STATIONS).map(([key, { label }]) => (
 | 
			
		||||
          <button
 | 
			
		||||
            key={key}
 | 
			
		||||
            className={`px-3 py-1 rounded-full text-sm font-semibold transition-colors ${
 | 
			
		||||
              activeStation === key
 | 
			
		||||
                ? "bg-neutral-800 text-white dark:bg-white dark:text-black"
 | 
			
		||||
                : "bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
 | 
			
		||||
            }`}
 | 
			
		||||
            onClick={() => setActiveStation(key)}
 | 
			
		||||
            aria-pressed={activeStation === key}
 | 
			
		||||
          >
 | 
			
		||||
            {label}
 | 
			
		||||
          </button>
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="c-containter">
 | 
			
		||||
        <div className="music-container mt-8">
 | 
			
		||||
          <section className="album-cover">
 | 
			
		||||
            <img src={coverArt} className="cover" alt="Cover Art" />
 | 
			
		||||
          </section>
 | 
			
		||||
          <section className="music-player">
 | 
			
		||||
            <h1 className="music-player__header">serious.FM</h1>
 | 
			
		||||
            <h1 className="music-player__title">{trackTitle}</h1>
 | 
			
		||||
            <h2 className="music-player__author">{trackArtist}</h2>
 | 
			
		||||
            {trackGenre && <h2 className="music-player__genre">{trackGenre}</h2>}
 | 
			
		||||
            <div className="music-time">
 | 
			
		||||
              <p className="music-time__current">{formatTime(elapsed)}</p>
 | 
			
		||||
              <p className="music-time__last">{formatTime(duration - elapsed)}</p>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="music-bar" id="progress">
 | 
			
		||||
              <div id="length" style={{ width: `${progress}%` }}></div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="music-control">
 | 
			
		||||
              <div className="music-control__play" id="play">
 | 
			
		||||
                {!isPlaying ? (
 | 
			
		||||
                  <Play onClick={togglePlayback} />
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <Pause onClick={togglePlayback} />
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </section>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )};
 | 
			
		||||
      </div>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ import { metaData } from "../config";
 | 
			
		||||
import { SEO } from "astro-seo";
 | 
			
		||||
import { getImagePath } from "astro-opengraph-images";
 | 
			
		||||
import { JoyUIRootIsland } from "./Components"
 | 
			
		||||
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr"; // your existing theme hook
 | 
			
		||||
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr"; 
 | 
			
		||||
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
 | 
			
		||||
 | 
			
		||||
const { title, description = metaData.description, image } = Astro.props;
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,9 @@ import RandomMsg from "../components/RandomMsg";
 | 
			
		||||
import { buildTime } from '../utils/buildTime.js';
 | 
			
		||||
 | 
			
		||||
const YEAR = new Date().getFullYear();
 | 
			
		||||
var ENVIRONMENT = (Astro.url.hostname === "localhost") ? "Dev": "Prod";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
<div class="footer">
 | 
			
		||||
  <small class="block lg:mt-24 mt-16 text-[#1C1C1C] dark:text-[#D4D4D4] footer-text">
 | 
			
		||||
@@ -15,6 +18,7 @@ const YEAR = new Date().getFullYear();
 | 
			
		||||
  <div style="margin-top: 15px; bottom: 0%">
 | 
			
		||||
    <small>Built: {buildTime} UTC</small>
 | 
			
		||||
  </div>
 | 
			
		||||
  <span style="font-size: 0.75rem; font-style: italic; margin-left: 100%">{ENVIRONMENT}</span>
 | 
			
		||||
</div>
 | 
			
		||||
<style>
 | 
			
		||||
  @media screen and (max-width: 480px) {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,79 +2,84 @@
 | 
			
		||||
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 },
 | 
			
		||||
  "divider-1": { name: "", className: "", icon: HorizontalRuleIcon },
 | 
			
		||||
  "/radio": { name: "Radio", className: "", icon: null },
 | 
			
		||||
  "divider-2": { name: "", className: "", icon: HorizontalRuleIcon },
 | 
			
		||||
  "https://status.boatson.boats": { name: "Status", className: "", icon: ExitToApp },
 | 
			
		||||
  "divider-3": { name: "", className: "", icon: HorizontalRuleIcon },
 | 
			
		||||
  "https://kode.boatson.boats": { name: "Git", className: "", icon: ExitToApp },
 | 
			
		||||
  "divider-4": { name: "", className: "", icon: HorizontalRuleIcon },
 | 
			
		||||
  "https://old.codey.lol": { name: "Old Site", className: "", icon: ExitToApp },
 | 
			
		||||
};
 | 
			
		||||
const navItems = [
 | 
			
		||||
  { label: "Home", href: "/" },
 | 
			
		||||
  { label: "Radio", href: "/radio" },
 | 
			
		||||
  { blockSeparator: true },
 | 
			
		||||
  { label: "Status", href: "https://status.boatson.boats", icon: ExitToApp },
 | 
			
		||||
  { label: "Git", href: "https://kode.boatson.boats", icon: ExitToApp },
 | 
			
		||||
  { label: "Old Site", href: "https://old.codey.lol", icon: ExitToApp },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const currentPath = Astro.url.pathname;
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<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() {
 | 
			
		||||
  toggleTheme = () => {
 | 
			
		||||
    console.log("Toggle!")
 | 
			
		||||
    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);
 | 
			
		||||
  });
 | 
			
		||||
    document.dispatchEvent(new CustomEvent("set-theme", { detail: newTheme }));
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
<nav class="w-full px-4 py-4 bg-transparent">
 | 
			
		||||
  <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
 | 
			
		||||
    <a href="/" class="text-xl font-semibold header-text whitespace-nowrap">
 | 
			
		||||
      {metaData.title}
 | 
			
		||||
    </a>
 | 
			
		||||
 | 
			
		||||
    <ul class="flex flex-wrap items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
 | 
			
		||||
      {navItems.map((item, index) => {
 | 
			
		||||
        if (item.blockSeparator) {
 | 
			
		||||
          return (
 | 
			
		||||
            <li class="text-neutral-500 dark:text-neutral-500 px-2 select-none" aria-hidden="true">
 | 
			
		||||
              ‖
 | 
			
		||||
            </li>
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const isExternal = item.href?.startsWith("http");
 | 
			
		||||
        const isActive = item.href === currentPath;
 | 
			
		||||
        const nextItem = navItems[index + 1];
 | 
			
		||||
        const shouldShowThinBar = nextItem && !nextItem.blockSeparator;
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
          <>
 | 
			
		||||
            <li>
 | 
			
		||||
              <a
 | 
			
		||||
                href={item.href}
 | 
			
		||||
                class={`flex items-center gap-1 px-2 py-1 rounded-md transition-colors
 | 
			
		||||
                  hover:bg-neutral-200 dark:hover:bg-neutral-800
 | 
			
		||||
                  ${isActive ? "font-semibold underline underline-offset-4" : ""}`}
 | 
			
		||||
                target={isExternal ? "_blank" : undefined}
 | 
			
		||||
                rel={isExternal ? "noopener noreferrer" : undefined}
 | 
			
		||||
              >
 | 
			
		||||
                {item.label}
 | 
			
		||||
                {item.icon === ExitToApp && <ExitToApp className="w-4 h-4" client:load />}
 | 
			
		||||
              </a>
 | 
			
		||||
            </li>
 | 
			
		||||
            {shouldShowThinBar && (
 | 
			
		||||
              <li class="text-neutral-400 dark:text-neutral-600 hidden sm:inline select-none" aria-hidden="true">
 | 
			
		||||
                |
 | 
			
		||||
              </li>
 | 
			
		||||
            )}
 | 
			
		||||
          </>
 | 
			
		||||
        );
 | 
			
		||||
      })}
 | 
			
		||||
      <li>
 | 
			
		||||
        <button
 | 
			
		||||
          id="theme-toggle"
 | 
			
		||||
          aria-label="Toggle theme"
 | 
			
		||||
          type="button"
 | 
			
		||||
          class="flex items-center justify-center px-2 py-1 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-opacity"
 | 
			
		||||
          onclick="toggleTheme()"
 | 
			
		||||
        >
 | 
			
		||||
          <Icon
 | 
			
		||||
            name="fa6-solid:circle-half-stroke"
 | 
			
		||||
            class="h-4 w-4 text-[#1c1c1c] dark:text-[#D4D4D4]"
 | 
			
		||||
          />
 | 
			
		||||
        </button>
 | 
			
		||||
      </li>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </div>
 | 
			
		||||
</nav>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user