Initial open-source self-hosted Songs2YT edition
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { Children, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const PANEL_MS = 420;
|
||||
const ITEM_BASE_MS = 90;
|
||||
|
||||
function CloseIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileSidebar({ open, onClose, title = "Menu", children }: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
const frame = requestAnimationFrame(() => setVisible(true));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
const timer = window.setTimeout(() => setMounted(false), PANEL_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [mounted, onClose]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300 ease-out ${
|
||||
visible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close menu"
|
||||
style={{ animationDelay: visible ? "120ms" : "0ms" }}
|
||||
className={`fixed right-4 top-4 z-[60] flex h-10 w-10 items-center justify-center rounded-full border border-gray-700 bg-surface text-gray-300 shadow-lg transition-shadow hover:bg-surface-light hover:text-white ${
|
||||
visible ? "mobile-sidebar-close-btn-open" : "mobile-sidebar-close-btn-close"
|
||||
}`}
|
||||
>
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<aside
|
||||
className={`absolute right-0 top-0 flex h-full w-72 max-w-[85vw] flex-col border-l border-gray-800 bg-surface shadow-2xl ${
|
||||
visible ? "mobile-sidebar-panel-open" : "mobile-sidebar-panel-close"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`border-b border-gray-800 px-5 py-5 transition-all duration-300 ease-out ${
|
||||
visible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
|
||||
}`}
|
||||
style={{ transitionDelay: visible ? "100ms" : "0ms" }}
|
||||
>
|
||||
<span className="text-sm font-semibold uppercase tracking-wider text-gray-400">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-4">
|
||||
{Children.map(children, (child, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`transition-all duration-300 ease-out ${
|
||||
visible ? "translate-x-0 opacity-100" : "translate-x-8 opacity-0"
|
||||
}`}
|
||||
style={{
|
||||
transitionDelay: visible ? `${ITEM_BASE_MS + index * 50}ms` : "0ms",
|
||||
}}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileMenuButton({
|
||||
onClick,
|
||||
open = false,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
open?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
className="relative rounded p-2 text-gray-300 transition-all duration-300 hover:scale-105 hover:bg-white/10 hover:text-white active:scale-95 sm:hidden"
|
||||
>
|
||||
<span className="relative block h-6 w-6">
|
||||
<span
|
||||
className={`absolute left-0 top-[5px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
|
||||
open ? "top-[11px] rotate-45" : ""
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-[11px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
|
||||
open ? "scale-x-0 opacity-0" : ""
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-[17px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
|
||||
open ? "top-[11px] -rotate-45" : ""
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function sidebarLinkClass(active = false) {
|
||||
return `block rounded-lg px-4 py-3 text-base font-medium transition-all duration-200 hover:translate-x-0.5 ${
|
||||
active ? "bg-surface-light text-white" : "text-gray-300 hover:bg-surface-light hover:text-white"
|
||||
}`;
|
||||
}
|
||||
|
||||
export function dashboardSidebarLinkClass(active = false, danger = false) {
|
||||
const classes = ["mobile-sidebar-link"];
|
||||
if (active) classes.push("mobile-sidebar-link-active");
|
||||
if (danger) classes.push("mobile-sidebar-link-danger");
|
||||
return classes.join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user