feat (v1.0.0): initial refactor and redesign
This commit is contained in:
parent
3058aa1ab4
commit
fe9b50b30e
134 changed files with 17792 additions and 3670 deletions
|
|
@ -1,21 +0,0 @@
|
|||
import { TbCopyrightOff } from "react-icons/tb"
|
||||
import { RxDividerVertical } from "react-icons/rx"
|
||||
import Link from 'next/link'
|
||||
import RandomFooterMsg from "./objects/RandomFooterMsg"
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-gray-800 text-gray-400 py-4">
|
||||
<div className="flex flex-col sm:flex-row container mx-auto px-4 text-center items-center justify-center">
|
||||
<Link href="https://git.p0ntus.com/aidan/aidxnCC" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors mb-2 sm:mb-0">
|
||||
<div className="flex items-center justify-center">
|
||||
<TbCopyrightOff className="text-md mr-2" />
|
||||
Open Source and Copyright-Free
|
||||
</div>
|
||||
</Link>
|
||||
<RxDividerVertical className="hidden sm:block mx-4"/>
|
||||
<RandomFooterMsg />
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,473 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
House,
|
||||
Link as LinkIcon,
|
||||
User,
|
||||
Phone,
|
||||
BookOpen,
|
||||
X,
|
||||
Menu,
|
||||
Globe,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Brain,
|
||||
Smartphone
|
||||
} from 'lucide-react'
|
||||
import { TbUserHeart } from 'react-icons/tb'
|
||||
import { SiClaude, SiGoogle } from 'react-icons/si'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface NavItemProps {
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const NavItem = ({ href, icon, children }: NavItemProps) => (
|
||||
<div className="nav-item">
|
||||
<Link href={href} className="flex items-center text-gray-300 hover:text-white hover:bg-gray-700 rounded-md px-3 py-2 transition-all duration-300">
|
||||
{React.createElement(icon, { className: "text-md mr-2", strokeWidth: 2.5, size: 20 })}
|
||||
{children}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface DropdownNavItemProps {
|
||||
id: string;
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
children: React.ReactNode;
|
||||
dropdownContent: React.ReactNode;
|
||||
isMobile?: boolean;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const DropdownNavItem = ({ id, href, icon, children, dropdownContent, isMobile = false, isOpen = false, onOpenChange }: DropdownNavItemProps) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
onOpenChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile && isOpen) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [isMobile, isOpen, onOpenChange]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile) {
|
||||
onOpenChange?.(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
if (!isMobile) {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
onOpenChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isMobile) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenChange?.(isOpen ? null : id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="nav-item relative"
|
||||
ref={dropdownRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
onClick={isMobile ? handleClick : undefined}
|
||||
className="flex items-center justify-between text-gray-300 hover:text-white hover:bg-gray-700 rounded-md px-3 py-2 transition-all duration-300 w-full"
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
{React.createElement(icon, { className: "text-md mr-2", strokeWidth: 2.5, size: 20 })}
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
<ChevronDown className={`ml-2 transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} strokeWidth={2.5} size={16} />
|
||||
</Link>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Invisible bridge to handle gap */}
|
||||
{!isMobile && (
|
||||
<div className="absolute left-0 top-full w-full h-1 z-50" />
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
isMobile
|
||||
? 'relative w-full mt-2 ml-5 pr-4'
|
||||
: 'absolute left-0 mt-1 z-50 flex'
|
||||
}`}
|
||||
>
|
||||
{dropdownContent}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NestedDropdownItemProps {
|
||||
children: React.ReactNode;
|
||||
nestedContent: React.ReactNode;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const NestedDropdownItem = ({ children, nestedContent, isMobile = false }: NestedDropdownItemProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile) {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
if (!isMobile) {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && itemRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => setIsOpen(false), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isMobile) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
ref={itemRef}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center justify-between w-full text-left px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300"
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
<Smartphone className="mr-3" strokeWidth={2.5} size={18} />
|
||||
{children}
|
||||
</span>
|
||||
<ChevronRight className={`transform transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`} strokeWidth={2.5} size={18} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="relative mt-2 ml-5 pr-4 space-y-1">
|
||||
{nestedContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
ref={itemRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center justify-between w-full text-left px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300"
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
<Smartphone className="mr-3" strokeWidth={2.5} size={18} />
|
||||
{children}
|
||||
</span>
|
||||
<ChevronDown className={`transform transition-transform duration-200 ${isOpen ? '-rotate-90' : ''}`} strokeWidth={2.5} size={18} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Invisible bridge to handle gap */}
|
||||
<div className="absolute left-full top-0 w-2 h-full z-50" />
|
||||
<div className="absolute left-full top-0 ml-2 w-64 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50">
|
||||
{nestedContent}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LanguageSelector = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const languages = [
|
||||
{ code: 'en-US', name: 'English' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 1024);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const changeLanguage = async (lng: string) => {
|
||||
await i18n.changeLanguage(lng);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
if (!isMobile) {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMobile) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
ref={dropdownRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`flex items-center ${isMobile ? 'justify-between' : ''} text-gray-300 hover:text-white hover:bg-gray-700 rounded-md px-3 py-2 transition-all duration-300 ${isMobile ? 'w-full' : ''}`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
<Globe className="text-md mr-2" strokeWidth={2.5} size={20} />
|
||||
<span>{languages.find(lang => lang.code === i18n.language)?.name || 'English'}</span>
|
||||
</span>
|
||||
<ChevronDown className={`ml-2 transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} strokeWidth={2.5} size={16} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Invisible bridge to handle gap */}
|
||||
{!isMobile && (
|
||||
<div className="absolute right-0 top-full w-56 h-2 z-50" />
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
isMobile
|
||||
? 'relative w-full mt-2 ml-4 pr-4 space-y-1'
|
||||
: 'absolute right-0 mt-2 w-56 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50'
|
||||
}`}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="language-menu"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`block w-full text-left ${isMobile ? 'px-4 py-2.5' : 'px-5 py-3'} ${isMobile ? 'text-sm' : 'text-base'} rounded-md ${
|
||||
i18n.language === lang.code
|
||||
? 'text-white bg-gray-700/50'
|
||||
: 'text-gray-300 hover:text-white hover:bg-gray-700/50'
|
||||
} transition-all duration-300`}
|
||||
role="menuitem"
|
||||
>
|
||||
{lang.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Header() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsOpen(!isOpen);
|
||||
if (isOpen) {
|
||||
setActiveDropdown(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropdownChange = (id: string | null) => {
|
||||
setActiveDropdown(id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 1024);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const aboutDropdownContent = (
|
||||
<>
|
||||
<div className={`${isMobile ? 'w-full' : 'w-64 bg-gray-800 rounded-lg shadow-xl border border-gray-700'}`}>
|
||||
<Link href="/about" className={`flex items-center px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300 cursor-pointer`}>
|
||||
<TbUserHeart className="mr-3" size={18} />
|
||||
Get to Know Me
|
||||
</Link>
|
||||
<NestedDropdownItem
|
||||
isMobile={isMobile}
|
||||
nestedContent={
|
||||
<>
|
||||
<Link href="/device/bonito" className={`flex items-center ${isMobile ? 'px-4 py-3' : 'px-5 py-3'} text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
|
||||
<SiGoogle className="mr-3" size={18} />
|
||||
Pixel 3a XL (bonito)
|
||||
</Link>
|
||||
<Link href="/device/cheetah" className={`flex items-center ${isMobile ? 'px-4 py-3' : 'px-5 py-3'} text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
|
||||
<SiGoogle className="mr-3" size={18} />
|
||||
Pixel 7 Pro (cheetah)
|
||||
</Link>
|
||||
<Link href="/device/komodo" className={`flex items-center ${isMobile ? 'px-4 py-3' : 'px-5 py-3'} text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
|
||||
<SiGoogle className="mr-3" size={18} />
|
||||
Pixel 9 Pro (komodo)
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Devices
|
||||
</NestedDropdownItem>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const aiDropdownContent = (
|
||||
<div className={`${isMobile ? 'w-full' : 'w-64 bg-gray-800 rounded-lg shadow-xl border border-gray-700'}`}>
|
||||
<Link href="/ai/claude" className={`flex items-center px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
|
||||
<SiClaude className="mr-3" size={18} />
|
||||
Claude Usage
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`fixed inset-0 z-30 pointer-events-none transition-all duration-300 ${
|
||||
activeDropdown && !isMobile
|
||||
? 'backdrop-blur-sm opacity-100'
|
||||
: 'backdrop-blur-none opacity-0'
|
||||
}`}
|
||||
/>
|
||||
<header className="bg-gray-800 relative">
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 backdrop-blur-md z-40 lg:hidden"
|
||||
onClick={toggleMenu}
|
||||
/>
|
||||
)}
|
||||
<nav className="container mx-auto px-4 py-4 flex justify-between items-center relative z-50">
|
||||
<Link href="/" className="text-gray-300 hover:text-white text-2xl font-bold transition-all duration-300 hover:glow">
|
||||
aidxn.cc
|
||||
</Link>
|
||||
<button onClick={toggleMenu} className="lg:hidden text-gray-300 focus:outline-hidden">
|
||||
{isOpen ? <X className="text-2xl" /> : <Menu className="text-2xl" />}
|
||||
</button>
|
||||
<ul className={`flex flex-col lg:flex-row space-y-3 lg:space-y-0 lg:space-x-4 absolute lg:static bg-gray-800 lg:bg-transparent w-full lg:w-auto left-0 lg:left-auto top-full lg:top-auto px-2 py-4 lg:p-0 transition-all duration-300 ease-in-out z-50 ${isOpen ? 'flex' : 'hidden lg:flex'}`}>
|
||||
<NavItem href="/" icon={House}>Home</NavItem>
|
||||
<DropdownNavItem
|
||||
id="about"
|
||||
href="/about"
|
||||
icon={User}
|
||||
dropdownContent={aboutDropdownContent}
|
||||
isMobile={isMobile}
|
||||
isOpen={activeDropdown === 'about'}
|
||||
onOpenChange={handleDropdownChange}
|
||||
>
|
||||
About Me
|
||||
</DropdownNavItem>
|
||||
<DropdownNavItem
|
||||
id="ai"
|
||||
href="/ai"
|
||||
icon={Brain}
|
||||
dropdownContent={aiDropdownContent}
|
||||
isMobile={isMobile}
|
||||
isOpen={activeDropdown === 'ai'}
|
||||
onOpenChange={handleDropdownChange}
|
||||
>
|
||||
AI
|
||||
</DropdownNavItem>
|
||||
<NavItem href="/contact" icon={Phone}>Contact</NavItem>
|
||||
<NavItem href="/domains" icon={LinkIcon}>Domains</NavItem>
|
||||
<NavItem href="/manifesto" icon={BookOpen}>Manifesto</NavItem>
|
||||
<div className="lg:hidden mt-2 pt-3 -mb-1.5 border-t border-gray-600/30">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</ul>
|
||||
<div className="hidden lg:block">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import "../i18n";
|
||||
|
||||
export default function I18nProvider({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
81
components/device/DeviceHero.tsx
Normal file
81
components/device/DeviceHero.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import Image from 'next/image';
|
||||
|
||||
import type { DeviceHeroProps } from '@/lib/types';
|
||||
import { deviceTypeLabels } from '@/lib/devices/config';
|
||||
|
||||
export default function DeviceHero({ device }: DeviceHeroProps) {
|
||||
const imageWidth = device.heroImage.width ?? 540;
|
||||
const imageHeight = device.heroImage.height ?? 540;
|
||||
|
||||
const metadata = [
|
||||
{
|
||||
label: 'Type',
|
||||
value: deviceTypeLabels[device.type],
|
||||
},
|
||||
device.releaseYear
|
||||
? {
|
||||
label: 'Release',
|
||||
value: device.releaseYear.toString(),
|
||||
}
|
||||
: undefined,
|
||||
device.status
|
||||
? {
|
||||
label: 'Status',
|
||||
value: device.status,
|
||||
}
|
||||
: undefined,
|
||||
device.codename
|
||||
? {
|
||||
label: 'Codename',
|
||||
value: device.codename,
|
||||
}
|
||||
: undefined,
|
||||
].filter(Boolean) as Array<{ label: string; value: string }>;
|
||||
|
||||
return (
|
||||
<section className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] gap-8 xl:gap-12">
|
||||
<div className="bg-gray-900/60 border border-gray-800 rounded-2xl p-6 md:p-8 backdrop-blur-sm space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-3xl md:text-4xl font-semibold text-gray-100">
|
||||
{device.name}
|
||||
</h1>
|
||||
{device.tagline ? (
|
||||
<p className="text-base md:text-lg text-gray-400 max-w-2xl">{device.tagline}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{device.summary?.length ? (
|
||||
<div className="space-y-3 text-sm md:text-base text-gray-400 leading-relaxed max-w-2xl">
|
||||
{device.summary.map((paragraph, idx) => (
|
||||
<p key={idx}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{metadata.length ? (
|
||||
<dl className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 text-sm text-gray-400">
|
||||
{metadata.map((item) => (
|
||||
<div key={item.label} className="flex flex-col">
|
||||
<dt className="uppercase text-xs tracking-wide text-gray-600">{item.label}</dt>
|
||||
<dd className="font-medium text-gray-200">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-full max-w-md rounded-2xl border border-gray-800 bg-gray-900/60 p-6 md:p-8 flex items-center justify-center">
|
||||
<Image
|
||||
src={device.heroImage.src}
|
||||
alt={device.heroImage.alt}
|
||||
width={imageWidth}
|
||||
height={imageHeight}
|
||||
className="w-full h-auto object-contain drop-shadow-lg"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
283
components/device/DevicePageShell.tsx
Normal file
283
components/device/DevicePageShell.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { ArrowUpRight, Star, StarHalf, StarOff } from 'lucide-react';
|
||||
|
||||
import Link from '@/components/objects/Link';
|
||||
import type {
|
||||
DevicePageShellProps,
|
||||
DeviceStatGroup,
|
||||
StatsGridProps,
|
||||
StatItemProps,
|
||||
SectionsGridProps,
|
||||
SectionCardProps,
|
||||
SectionRowProps,
|
||||
RatingProps,
|
||||
StarState,
|
||||
} from '@/lib/types';
|
||||
import { isExternalHref, externalLinkProps } from '@/lib/utils/styles';
|
||||
import { iconSizes } from '@/lib/devices/config';
|
||||
|
||||
import DeviceHero from './DeviceHero';
|
||||
|
||||
export default function DevicePageShell({ device }: DevicePageShellProps): ReactElement {
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<DeviceHero device={device} />
|
||||
|
||||
{device.stats.length ? <StatsGrid stats={device.stats} /> : null}
|
||||
|
||||
{device.sections.length ? <SectionsGrid sections={device.sections} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsGrid({ stats }: StatsGridProps): ReactElement {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<h2 className="text-xl font-semibold text-gray-100">At a glance</h2>
|
||||
<div className="grid gap-5 lg:grid-cols-2 xl:grid-cols-3 auto-rows-fr">
|
||||
{stats.map((group) => (
|
||||
<StatCard key={group.title} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ group }: { group: DeviceStatGroup }): ReactElement {
|
||||
const Icon = group.icon;
|
||||
|
||||
return (
|
||||
<article className="flex h-full flex-col gap-4 rounded-2xl border border-gray-800 bg-gray-900/60 p-5 backdrop-blur-sm">
|
||||
<header className="flex items-center gap-3">
|
||||
{Icon ? (
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gray-800 text-gray-300">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
) : null}
|
||||
<h3 className="text-lg font-semibold text-gray-100">{group.title}</h3>
|
||||
</header>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{group.items.map((item) => (
|
||||
<StatItem
|
||||
key={`${group.title}-${item.label ?? item.value}`}
|
||||
item={item}
|
||||
groupIcon={group.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ item, groupIcon }: StatItemProps): ReactElement {
|
||||
const isExternal = isExternalHref(item.href);
|
||||
const linkProps = isExternal ? externalLinkProps : {};
|
||||
const baseClasses =
|
||||
'relative overflow-hidden rounded-2xl border border-gray-800 bg-gray-900/70 px-4 py-5 text-gray-100 transition';
|
||||
const GroupIcon = groupIcon;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{GroupIcon ? (
|
||||
<GroupIcon
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -top-4 -right-4 text-gray-800/70"
|
||||
size={iconSizes.stat}
|
||||
/>
|
||||
) : null}
|
||||
{item.href && isExternal ? (
|
||||
<ArrowUpRight
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute bottom-4 right-4 z-20 text-gray-500"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-10 space-y-2 pr-10">
|
||||
{item.label ? (
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">{item.label}</p>
|
||||
) : null}
|
||||
<div className="text-lg font-semibold leading-snug text-gray-100">{item.value}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`${baseClasses} block hover:text-white hover:no-underline`}
|
||||
{...linkProps}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClasses}>{content}</div>;
|
||||
}
|
||||
|
||||
function SectionsGrid({ sections }: SectionsGridProps): ReactElement {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<h2 className="text-xl font-semibold text-gray-100">Deep dive</h2>
|
||||
<div className="grid gap-5 lg:grid-cols-2 xl:grid-cols-3 auto-rows-fr">
|
||||
{sections.map((section) => (
|
||||
<SectionCard key={section.id} section={section} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ section }: SectionCardProps): ReactElement {
|
||||
const Icon = section.icon;
|
||||
const shouldSpanWide =
|
||||
!!section.paragraphs?.length && (!section.rows || section.paragraphs.length > 1);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`rounded-2xl border border-gray-800 bg-gray-900/60 p-5 backdrop-blur-sm flex flex-col gap-4 ${
|
||||
shouldSpanWide ? 'lg:col-span-2 xl:col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<header className="flex items-center gap-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gray-800 text-gray-300">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100">{section.title}</h3>
|
||||
{section.rating ? <Rating rating={section.rating} /> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{section.rows?.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{section.rows.map((row) => (
|
||||
<SectionRow key={row.label} row={row} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{section.listItems?.length ? (
|
||||
<ul className="grid gap-2 text-sm text-gray-300">
|
||||
{section.listItems.map((item) => {
|
||||
const isExternal = isExternalHref(item.href);
|
||||
const linkProps = isExternal ? externalLinkProps : {};
|
||||
return (
|
||||
<li key={item.label}>
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="relative block rounded-xl border border-gray-800 bg-gray-900/70 px-3 py-2 text-gray-100 transition hover:text-white hover:no-underline"
|
||||
{...linkProps}
|
||||
>
|
||||
<span className="block pr-10 font-medium">{item.label}</span>
|
||||
{isExternal ? (
|
||||
<ArrowUpRight
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute bottom-2.5 right-3 text-gray-500"
|
||||
/>
|
||||
) : null}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900/70 px-3 py-2 text-gray-100">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.description ? (
|
||||
<p className="mt-1 text-xs text-gray-500">{item.description}</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{section.paragraphs?.length ? (
|
||||
<div className="space-y-3 text-sm leading-relaxed text-gray-400">
|
||||
{section.paragraphs.map((paragraph) => (
|
||||
<p key={`${section.id}-${paragraph}`}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionRow({ row }: SectionRowProps): ReactElement {
|
||||
const { icon: RowIcon } = row;
|
||||
const isExternal = isExternalHref(row.href);
|
||||
const linkProps = isExternal ? externalLinkProps : {};
|
||||
const baseClasses =
|
||||
'relative overflow-hidden rounded-2xl border border-gray-800 bg-gray-900/70 px-4 py-5 text-gray-100 transition';
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{RowIcon ? (
|
||||
<RowIcon className="pointer-events-none absolute -top-4 -right-4 text-gray-800/70" size={iconSizes.section} />
|
||||
) : null}
|
||||
{row.href && isExternal ? (
|
||||
<ArrowUpRight
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute bottom-4 right-4 z-20 h-4 w-4 text-gray-500"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-10 space-y-2 pr-10">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">{row.label}</p>
|
||||
<div className="text-lg font-semibold leading-snug text-gray-100">{row.value}</div>
|
||||
{row.note ? <p className="text-xs text-gray-500">{row.note}</p> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (row.href) {
|
||||
return (
|
||||
<Link
|
||||
href={row.href}
|
||||
className={`${baseClasses} block hover:text-white hover:no-underline`}
|
||||
{...linkProps}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClasses}>{content}</div>;
|
||||
}
|
||||
|
||||
function Rating({ rating }: RatingProps): ReactElement {
|
||||
const stars = buildStars(rating.value, rating.scale ?? 5);
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-gray-400">
|
||||
<span className="flex items-center text-gray-200">
|
||||
{stars.map((state, idx) => {
|
||||
const key = `${rating.label ?? rating.value}-${idx}`;
|
||||
if (state === 'full') {
|
||||
return <Star key={key} className="fill-current" />;
|
||||
}
|
||||
if (state === 'half') {
|
||||
return <StarHalf key={key} className="fill-current" />;
|
||||
}
|
||||
return <StarOff key={key} className="text-gray-600" />;
|
||||
})}
|
||||
</span>
|
||||
<span className="text-gray-300">{rating.value.toFixed(1)}</span>
|
||||
{rating.label ? <span className="text-xs uppercase tracking-wide text-gray-600">{rating.label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildStars(value: number, scale: number): StarState[] {
|
||||
const stars: StarState[] = [];
|
||||
const normalized = Math.max(0, Math.min(value, scale));
|
||||
for (let i = 1; i <= scale; i += 1) {
|
||||
if (normalized >= i) {
|
||||
stars.push('full');
|
||||
} else if (normalized > i - 1 && normalized < i) {
|
||||
stars.push('half');
|
||||
} else {
|
||||
stars.push('empty');
|
||||
}
|
||||
}
|
||||
return stars;
|
||||
}
|
||||
257
components/docs/APIEndpointDoc.tsx
Normal file
257
components/docs/APIEndpointDoc.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { colors } from '@/lib/theme'
|
||||
import type { APIEndpoint } from '@/lib/docs/types'
|
||||
import CodeBlock from './CodeBlock'
|
||||
import { LuLock } from 'react-icons/lu'
|
||||
|
||||
interface APIEndpointDocProps {
|
||||
endpoint: APIEndpoint
|
||||
className?: string
|
||||
}
|
||||
|
||||
const methodStyles = {
|
||||
GET: {
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
color: colors.accents.success,
|
||||
borderColor: 'rgba(16, 185, 129, 0.3)',
|
||||
},
|
||||
POST: {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
color: colors.accents.info,
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
PUT: {
|
||||
backgroundColor: colors.accents.warningBg,
|
||||
color: colors.accents.warning,
|
||||
borderColor: 'rgba(245, 158, 11, 0.3)',
|
||||
},
|
||||
DELETE: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
color: colors.accents.error,
|
||||
borderColor: 'rgba(239, 68, 68, 0.3)',
|
||||
},
|
||||
PATCH: {
|
||||
backgroundColor: 'rgba(168, 85, 247, 0.1)',
|
||||
color: '#a855f7',
|
||||
borderColor: 'rgba(168, 85, 247, 0.3)',
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function APIEndpointDoc({
|
||||
endpoint,
|
||||
className,
|
||||
}: APIEndpointDocProps) {
|
||||
return (
|
||||
<div id={endpoint.id} className={cn('scroll-mt-20', className)}>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="rounded-md border px-3 py-1 text-sm font-bold"
|
||||
style={methodStyles[endpoint.method]}
|
||||
>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<code className="text-lg font-mono" style={{ color: colors.text.secondary }}>
|
||||
{endpoint.path}
|
||||
</code>
|
||||
</div>
|
||||
<p className="leading-relaxed" style={{ color: colors.text.body }}>{endpoint.description}</p>
|
||||
{endpoint.auth?.required && (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.3)',
|
||||
backgroundColor: colors.accents.warningBg,
|
||||
color: colors.accents.warning,
|
||||
}}
|
||||
>
|
||||
<LuLock className="h-4 w-4" />
|
||||
<span>
|
||||
Authentication required
|
||||
{endpoint.auth.type && `: ${endpoint.auth.type}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Query Parameters */}
|
||||
{endpoint.parameters?.query && endpoint.parameters.query.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.body }}>
|
||||
Query Parameters
|
||||
</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b" style={{ borderColor: colors.borders.default }}>
|
||||
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{endpoint.parameters.query.map((param, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className="border-b last:border-0"
|
||||
style={{ borderColor: colors.borders.subtle }}
|
||||
>
|
||||
<td className="px-4 py-3 font-mono" style={{ color: colors.text.secondary }}>
|
||||
{param.name}
|
||||
{!param.optional && (
|
||||
<span className="ml-1" style={{ color: colors.accents.error }}>*</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono" style={{ color: colors.text.muted }}>
|
||||
{param.type}
|
||||
</td>
|
||||
<td className="px-4 py-3" style={{ color: colors.text.body }}>
|
||||
{param.description}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Request Body */}
|
||||
{endpoint.parameters?.body && endpoint.parameters.body.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.body }}>Request Body</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b" style={{ borderColor: colors.borders.default }}>
|
||||
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Field
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{endpoint.parameters.body.map((param, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className="border-b last:border-0"
|
||||
style={{ borderColor: colors.borders.subtle }}
|
||||
>
|
||||
<td className="px-4 py-3 font-mono" style={{ color: colors.text.secondary }}>
|
||||
{param.name}
|
||||
{!param.optional && (
|
||||
<span className="ml-1" style={{ color: colors.accents.error }}>*</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono" style={{ color: colors.text.muted }}>
|
||||
{param.type}
|
||||
</td>
|
||||
<td className="px-4 py-3" style={{ color: colors.text.body }}>
|
||||
{param.description}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Responses */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.body }}>Responses</h4>
|
||||
{endpoint.responses.map((response, index) => {
|
||||
const isSuccess = response.status >= 200 && response.status < 300
|
||||
const isError = response.status >= 400
|
||||
const statusStyle = isSuccess
|
||||
? { backgroundColor: 'rgba(16, 185, 129, 0.1)', color: colors.accents.success }
|
||||
: isError
|
||||
? { backgroundColor: 'rgba(239, 68, 68, 0.1)', color: colors.accents.error }
|
||||
: { backgroundColor: 'rgba(59, 130, 246, 0.1)', color: colors.accents.info }
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="space-y-2 rounded-lg border p-4"
|
||||
style={{
|
||||
borderColor: colors.borders.default,
|
||||
backgroundColor: colors.backgrounds.card,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="rounded px-2 py-1 text-sm font-mono font-semibold"
|
||||
style={statusStyle}
|
||||
>
|
||||
{response.status}
|
||||
</span>
|
||||
<span className="text-sm" style={{ color: colors.text.body }}>
|
||||
{response.description}
|
||||
</span>
|
||||
</div>
|
||||
{response.example && (
|
||||
<CodeBlock
|
||||
code={JSON.stringify(response.example, null, 2)}
|
||||
language="json"
|
||||
title="Example Response"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Examples */}
|
||||
{endpoint.examples && endpoint.examples.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.body }}>
|
||||
Request Examples
|
||||
</h4>
|
||||
{endpoint.examples.map((example, index) => (
|
||||
<div key={index} className="space-y-3">
|
||||
{example.title && (
|
||||
<h5 className="text-sm font-medium" style={{ color: colors.text.muted }}>
|
||||
{example.title}
|
||||
</h5>
|
||||
)}
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<CodeBlock
|
||||
code={
|
||||
typeof example.request === 'string'
|
||||
? example.request
|
||||
: JSON.stringify(example.request, null, 2)
|
||||
}
|
||||
language="bash"
|
||||
title="Request"
|
||||
/>
|
||||
<CodeBlock
|
||||
code={
|
||||
typeof example.response === 'string'
|
||||
? example.response
|
||||
: JSON.stringify(example.response, null, 2)
|
||||
}
|
||||
language="json"
|
||||
title="Response"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
components/docs/CodeBlock.tsx
Normal file
198
components/docs/CodeBlock.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { colors, effects } from '@/lib/theme'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Supported syntax highlighting languages for code blocks.
|
||||
*
|
||||
* @remarks
|
||||
* This list includes the most commonly used languages in the codebase.
|
||||
* Languages are validated and normalized to ensure proper syntax highlighting.
|
||||
*/
|
||||
const SUPPORTED_LANGUAGES = [
|
||||
'typescript',
|
||||
'javascript',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'ts',
|
||||
'js',
|
||||
'json',
|
||||
'bash',
|
||||
'shell',
|
||||
'css',
|
||||
'scss',
|
||||
'html',
|
||||
'markdown',
|
||||
'yaml',
|
||||
'sql',
|
||||
] as const
|
||||
|
||||
type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number]
|
||||
|
||||
/**
|
||||
* Normalizes language identifiers to their canonical forms.
|
||||
*
|
||||
* @param language - Raw language identifier from code fence
|
||||
* @returns Normalized language identifier for syntax highlighting
|
||||
*
|
||||
* @remarks
|
||||
* **Normalization rules:**
|
||||
* - 'ts' → 'typescript'
|
||||
* - 'js' → 'javascript'
|
||||
* - Invalid languages → 'typescript' (safe default)
|
||||
* - All other valid languages → unchanged
|
||||
*
|
||||
* This ensures consistent syntax highlighting even when JSDoc
|
||||
* examples use shorthand language identifiers.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* normalizeLanguage('ts') // Returns: 'typescript'
|
||||
* normalizeLanguage('tsx') // Returns: 'tsx'
|
||||
* normalizeLanguage('invalid') // Returns: 'typescript'
|
||||
* ```
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function normalizeLanguage(language: string): SupportedLanguage {
|
||||
const normalized = language.toLowerCase()
|
||||
|
||||
// Map common shorthands to full names
|
||||
if (normalized === 'ts') return 'typescript'
|
||||
if (normalized === 'js') return 'javascript'
|
||||
|
||||
// Validate against supported languages
|
||||
if (SUPPORTED_LANGUAGES.includes(normalized as SupportedLanguage)) {
|
||||
return normalized as SupportedLanguage
|
||||
}
|
||||
|
||||
// Default to typescript for unknown languages
|
||||
return 'typescript'
|
||||
}
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string
|
||||
language?: string
|
||||
title?: string
|
||||
showLineNumbers?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function CodeBlock({
|
||||
code,
|
||||
language = 'typescript',
|
||||
title,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const normalizedLanguage = normalizeLanguage(language)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('group relative', className)}>
|
||||
{title && (
|
||||
<div
|
||||
className="flex items-center justify-between rounded-t-lg border-2 border-b-0 px-4 py-2.5"
|
||||
style={{
|
||||
borderColor: colors.borders.default,
|
||||
backgroundColor: colors.backgrounds.card
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-medium" style={{ color: colors.text.secondary }}>
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-xs font-mono" style={{ color: colors.text.disabled }}>
|
||||
{normalizedLanguage}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-x-auto',
|
||||
title ? 'rounded-b-lg' : 'rounded-lg',
|
||||
'border-2'
|
||||
)}
|
||||
style={{
|
||||
borderColor: colors.borders.default,
|
||||
backgroundColor: colors.backgrounds.cardSolid
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'absolute right-3 top-3 z-10',
|
||||
'rounded-md px-3 py-1.5',
|
||||
'text-xs font-medium',
|
||||
'flex items-center gap-1.5',
|
||||
'opacity-0 transition-all duration-200',
|
||||
'group-hover:opacity-100',
|
||||
copied && 'opacity-100',
|
||||
effects.transitions.all
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: colors.backgrounds.card,
|
||||
color: copied ? colors.accents.success : colors.text.muted,
|
||||
borderWidth: '2px',
|
||||
borderColor: copied ? colors.accents.success : colors.borders.default
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!copied) {
|
||||
e.currentTarget.style.backgroundColor = colors.backgrounds.hover
|
||||
e.currentTarget.style.borderColor = colors.borders.hover
|
||||
e.currentTarget.style.color = colors.text.secondary
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!copied) {
|
||||
e.currentTarget.style.backgroundColor = colors.backgrounds.card
|
||||
e.currentTarget.style.borderColor = colors.borders.default
|
||||
e.currentTarget.style.color = colors.text.muted
|
||||
}
|
||||
}}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<SyntaxHighlighter
|
||||
language={normalizedLanguage}
|
||||
style={vscDarkPlus}
|
||||
showLineNumbers={showLineNumbers}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
background: 'transparent',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
components/docs/DocsSearch.tsx
Normal file
144
components/docs/DocsSearch.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { colors, effects } from '@/lib/theme'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import type { DocItem } from '@/lib/docs/types'
|
||||
|
||||
interface DocsSearchProps {
|
||||
items: DocItem[]
|
||||
onSearch: (query: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function DocsSearch({
|
||||
items,
|
||||
onSearch,
|
||||
className,
|
||||
}: DocsSearchProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Keyboard shortcut (Cmd/Ctrl + K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
inputRef.current?.blur()
|
||||
setQuery('')
|
||||
onSearch('')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onSearch])
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setQuery(value)
|
||||
onSearch(value)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('')
|
||||
onSearch('')
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center',
|
||||
'rounded-lg border-2',
|
||||
effects.transitions.colors
|
||||
)}
|
||||
style={{
|
||||
borderColor: isFocused ? colors.borders.hover : colors.borders.default,
|
||||
backgroundColor: colors.backgrounds.card
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isFocused) {
|
||||
e.currentTarget.style.borderColor = colors.borders.hover
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isFocused) {
|
||||
e.currentTarget.style.borderColor = colors.borders.default
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
className="absolute left-3 h-5 w-5"
|
||||
style={{ color: colors.text.disabled }}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder="Search documentation..."
|
||||
className={cn(
|
||||
'w-full bg-transparent px-10 py-3',
|
||||
'text-sm outline-none'
|
||||
)}
|
||||
style={{
|
||||
color: colors.text.primary,
|
||||
caretColor: colors.text.secondary
|
||||
}}
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className={cn(
|
||||
'absolute right-3 rounded p-1',
|
||||
effects.transitions.colors
|
||||
)}
|
||||
style={{ color: colors.text.disabled }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = colors.backgrounds.hover
|
||||
e.currentTarget.style.color = colors.text.secondary
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
e.currentTarget.style.color = colors.text.disabled
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<kbd
|
||||
className={cn(
|
||||
'absolute right-3',
|
||||
'rounded border px-2 py-1 text-xs font-mono'
|
||||
)}
|
||||
style={{
|
||||
borderColor: colors.borders.default,
|
||||
backgroundColor: colors.backgrounds.cardSolid,
|
||||
color: colors.text.disabled
|
||||
}}
|
||||
>
|
||||
⌘K
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{query && (
|
||||
<div
|
||||
className="mt-2 text-xs"
|
||||
style={{ color: colors.text.disabled }}
|
||||
>
|
||||
{items.length} result{items.length !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
components/docs/DocsSidebar.tsx
Normal file
210
components/docs/DocsSidebar.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { colors } from '@/lib/theme'
|
||||
import type { DocNavigation, DocCategory } from '@/lib/docs/types'
|
||||
import { Settings, Wrench, FileText, Palette, Globe, Package, ChevronDown, ChevronRight, X, Smartphone, Network, BookOpen } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface DocsSidebarProps {
|
||||
navigation: DocNavigation
|
||||
currentItemId?: string
|
||||
className?: string
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const categoryIcons: Record<DocCategory, LucideIcon> = {
|
||||
Services: Settings,
|
||||
Utils: Wrench,
|
||||
Types: FileText,
|
||||
Theme: Palette,
|
||||
Devices: Smartphone,
|
||||
Domains: Network,
|
||||
Docs: BookOpen,
|
||||
API: Globe,
|
||||
Other: Package,
|
||||
}
|
||||
|
||||
export default function DocsSidebar({
|
||||
navigation,
|
||||
currentItemId,
|
||||
className,
|
||||
onClose,
|
||||
}: DocsSidebarProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(navigation.sections.map((s) => s.title))
|
||||
)
|
||||
|
||||
const isMobileDrawer = !!onClose
|
||||
|
||||
const toggleSection = (title: string) => {
|
||||
const newExpanded = new Set(expandedSections)
|
||||
if (newExpanded.has(title)) {
|
||||
newExpanded.delete(title)
|
||||
} else {
|
||||
newExpanded.add(title)
|
||||
}
|
||||
setExpandedSections(newExpanded)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
isMobileDrawer
|
||||
? 'h-full w-full overflow-y-auto'
|
||||
: 'sticky top-20 h-[calc(100vh-8rem)] overflow-y-auto w-64',
|
||||
isMobileDrawer ? 'border-r-0' : 'border-r-2',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
borderColor: isMobileDrawer ? 'transparent' : colors.borders.default,
|
||||
backgroundColor: isMobileDrawer ? colors.backgrounds.cardSolid : 'transparent'
|
||||
}}
|
||||
>
|
||||
{/* Mobile Header with Close Button */}
|
||||
{isMobileDrawer && (
|
||||
<div
|
||||
className="sticky top-0 z-10 flex items-center justify-between p-4 border-b-2"
|
||||
style={{
|
||||
backgroundColor: colors.backgrounds.cardSolid,
|
||||
borderColor: colors.borders.default
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-semibold" style={{ color: colors.text.primary }}>
|
||||
Navigation
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-md p-2',
|
||||
'transition-colors duration-300'
|
||||
)}
|
||||
style={{ color: colors.text.muted }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = colors.backgrounds.hover
|
||||
e.currentTarget.style.color = colors.text.secondary
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
e.currentTarget.style.color = colors.text.muted
|
||||
}}
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="p-4 space-y-2">
|
||||
{navigation.sections.map((section) => {
|
||||
const isExpanded = expandedSections.has(section.title)
|
||||
const Icon = categoryIcons[section.category]
|
||||
|
||||
return (
|
||||
<div key={section.title} className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleSection(section.title)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-3 py-2',
|
||||
'text-sm font-medium',
|
||||
'transition-colors duration-300'
|
||||
)}
|
||||
style={{
|
||||
color: colors.text.secondary,
|
||||
backgroundColor: isExpanded ? colors.backgrounds.hover : 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isExpanded) {
|
||||
e.currentTarget.style.backgroundColor = colors.backgrounds.hover
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isExpanded) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="flex-1">{section.title}</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
color: colors.text.disabled,
|
||||
backgroundColor: colors.backgrounds.card
|
||||
}}
|
||||
>
|
||||
{section.items.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="ml-6 space-y-0.5">
|
||||
{section.items.map((item) => {
|
||||
const isActive = item.id === currentItemId
|
||||
|
||||
return (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
onClick={isMobileDrawer ? onClose : undefined}
|
||||
className={cn(
|
||||
'block rounded-md px-3 py-1.5',
|
||||
'text-sm transition-colors duration-300'
|
||||
)}
|
||||
style={{
|
||||
color: isActive ? colors.text.primary : colors.text.muted,
|
||||
backgroundColor: isActive ? colors.backgrounds.hover : 'transparent',
|
||||
fontWeight: isActive ? 500 : 400
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = colors.backgrounds.hover
|
||||
e.currentTarget.style.color = colors.text.secondary
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
e.currentTarget.style.color = colors.text.muted
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-mono px-1.5 py-0.5 rounded flex-shrink-0'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: colors.backgrounds.card,
|
||||
color: colors.text.disabled
|
||||
}}
|
||||
>
|
||||
{item.kind === 'function' && 'fn'}
|
||||
{item.kind === 'method' && 'fn'}
|
||||
{item.kind === 'class' && 'class'}
|
||||
{item.kind === 'interface' && 'interface'}
|
||||
{item.kind === 'type' && 'type'}
|
||||
{item.kind === 'variable' && 'const'}
|
||||
{item.kind === 'property' && 'prop'}
|
||||
{item.kind === 'enum' && 'enum'}
|
||||
</span>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
296
components/docs/FunctionDoc.tsx
Normal file
296
components/docs/FunctionDoc.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { colors, surfaces, effects } from '@/lib/theme'
|
||||
import type { DocItem } from '@/lib/docs/types'
|
||||
import CodeBlock from './CodeBlock'
|
||||
import TypeLink from './TypeLink'
|
||||
import { ExternalLink, TriangleAlert } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
interface FunctionDocProps {
|
||||
item: DocItem
|
||||
className?: string
|
||||
availableTypeIds?: Set<string>
|
||||
}
|
||||
|
||||
export default function FunctionDoc({ item, className, availableTypeIds }: FunctionDocProps) {
|
||||
return (
|
||||
<div id={item.id} className={cn('scroll-mt-20', className)}>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h3 className="text-2xl font-bold" style={{ color: colors.text.primary }}>
|
||||
{item.name}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: colors.backgrounds.card,
|
||||
color: colors.text.secondary
|
||||
}}
|
||||
>
|
||||
{item.kind}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: colors.accents.docsBg,
|
||||
color: colors.accents.docs,
|
||||
borderWidth: '1px',
|
||||
borderColor: colors.accents.docsBorder
|
||||
}}
|
||||
>
|
||||
{item.category}
|
||||
</span>
|
||||
{item.deprecated && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: colors.accents.warningBg,
|
||||
color: colors.accents.warning
|
||||
}}
|
||||
>
|
||||
<TriangleAlert className="h-3 w-3" />
|
||||
Deprecated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="leading-relaxed" style={{ color: colors.text.body }}>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{item.source && (
|
||||
<a
|
||||
href={`https://github.com/ihatenodejs/aidxnCC/blob/main/${item.source.file}#L${item.source.line}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-3 py-2',
|
||||
'text-xs border-2',
|
||||
effects.transitions.colors,
|
||||
'flex-shrink-0'
|
||||
)}
|
||||
style={{
|
||||
color: colors.text.muted,
|
||||
borderColor: colors.borders.default
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = colors.borders.hover
|
||||
e.currentTarget.style.color = colors.text.secondary
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = colors.borders.default
|
||||
e.currentTarget.style.color = colors.text.muted
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remarks */}
|
||||
{item.remarks && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border-l-4 pl-4 py-2',
|
||||
'space-y-2'
|
||||
)}
|
||||
style={{
|
||||
borderColor: colors.accents.ai,
|
||||
backgroundColor: colors.backgrounds.card
|
||||
}}
|
||||
>
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
Remarks
|
||||
</h4>
|
||||
<div className="text-sm leading-relaxed prose prose-invert prose-sm max-w-none" style={{ color: colors.text.body }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{item.remarks}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signature */}
|
||||
{item.signature && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
Signature
|
||||
</h4>
|
||||
<CodeBlock code={item.signature} language="typescript" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parameters */}
|
||||
{item.parameters && item.parameters.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="overflow-x-auto rounded-lg border-2" style={{ borderColor: colors.borders.default }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b-2" style={{ borderColor: colors.borders.default, backgroundColor: colors.backgrounds.card }}>
|
||||
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{item.parameters.map((param, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className="border-b last:border-0"
|
||||
style={{ borderColor: colors.borders.subtle }}
|
||||
>
|
||||
<td className="px-4 py-3 font-mono" style={{ color: colors.text.secondary }}>
|
||||
{param.name}
|
||||
{param.optional && (
|
||||
<span style={{ color: colors.text.disabled }}>?</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<TypeLink type={param.type} className="text-sm" availableTypeIds={availableTypeIds} />
|
||||
</td>
|
||||
<td className="px-4 py-3" style={{ color: colors.text.body }}>
|
||||
{param.description || '—'}
|
||||
{param.defaultValue && (
|
||||
<div className="mt-1 text-xs" style={{ color: colors.text.disabled }}>
|
||||
Default: <code>{param.defaultValue}</code>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Returns */}
|
||||
{item.returns && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
Returns
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border-2 p-4',
|
||||
'space-y-2'
|
||||
)}
|
||||
style={{
|
||||
borderColor: colors.borders.default,
|
||||
backgroundColor: colors.backgrounds.card
|
||||
}}
|
||||
>
|
||||
<TypeLink type={item.returns.type} className="text-sm" availableTypeIds={availableTypeIds} />
|
||||
{item.returns.description && (
|
||||
<p className="text-sm" style={{ color: colors.text.body }}>
|
||||
{item.returns.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Throws */}
|
||||
{item.throws && item.throws.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
Throws
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{item.throws.map((throwsDoc, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'rounded-lg border-2 p-4'
|
||||
)}
|
||||
style={{
|
||||
borderColor: colors.accents.warningBg,
|
||||
backgroundColor: colors.backgrounds.card
|
||||
}}
|
||||
>
|
||||
<p className="text-sm" style={{ color: colors.text.body }}>
|
||||
{throwsDoc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Examples */}
|
||||
{item.examples && item.examples.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
Examples
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{item.examples.map((example, index) => (
|
||||
<CodeBlock
|
||||
key={index}
|
||||
code={example.code}
|
||||
language={example.language}
|
||||
showLineNumbers
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(surfaces.badge.muted)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* See Also */}
|
||||
{item.see && item.see.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
See Also
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{item.see.map((ref, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm"
|
||||
style={{ color: colors.text.body }}
|
||||
>
|
||||
{ref}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
246
components/docs/TypeDoc.tsx
Normal file
246
components/docs/TypeDoc.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { colors, surfaces, effects } from '@/lib/theme'
|
||||
import type { DocItem } from '@/lib/docs/types'
|
||||
import CodeBlock from './CodeBlock'
|
||||
import TypeLink from './TypeLink'
|
||||
import { ExternalLink, TriangleAlert } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
interface TypeDocProps {
|
||||
item: DocItem
|
||||
className?: string
|
||||
availableTypeIds?: Set<string>
|
||||
}
|
||||
|
||||
export default function TypeDoc({ item, className, availableTypeIds }: TypeDocProps) {
|
||||
return (
|
||||
<div id={item.id} className={cn('scroll-mt-20', className)}>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h3 className="text-2xl font-bold" style={{ color: colors.text.primary }}>
|
||||
{item.name}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: colors.backgrounds.card,
|
||||
color: colors.text.secondary
|
||||
}}
|
||||
>
|
||||
{item.kind}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: colors.accents.docsBg,
|
||||
color: colors.accents.docs,
|
||||
borderWidth: '1px',
|
||||
borderColor: colors.accents.docsBorder
|
||||
}}
|
||||
>
|
||||
{item.category}
|
||||
</span>
|
||||
{item.deprecated && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: colors.accents.warningBg,
|
||||
color: colors.accents.warning
|
||||
}}
|
||||
>
|
||||
<TriangleAlert className="h-3 w-3" />
|
||||
Deprecated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="leading-relaxed" style={{ color: colors.text.body }}>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{item.source && (
|
||||
<a
|
||||
href={`https://github.com/ihatenodejs/aidxnCC/blob/main/${item.source.file}#L${item.source.line}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-3 py-2',
|
||||
'text-xs border-2',
|
||||
effects.transitions.colors,
|
||||
'flex-shrink-0'
|
||||
)}
|
||||
style={{
|
||||
color: colors.text.muted,
|
||||
borderColor: colors.borders.default
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = colors.borders.hover
|
||||
e.currentTarget.style.color = colors.text.secondary
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = colors.borders.default
|
||||
e.currentTarget.style.color = colors.text.muted
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remarks */}
|
||||
{item.remarks && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border-l-4 pl-4 py-2',
|
||||
'space-y-2'
|
||||
)}
|
||||
style={{
|
||||
borderColor: colors.accents.ai,
|
||||
backgroundColor: colors.backgrounds.card
|
||||
}}
|
||||
>
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
Remarks
|
||||
</h4>
|
||||
<div className="text-sm leading-relaxed prose prose-invert prose-sm max-w-none" style={{ color: colors.text.body }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{item.remarks}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type Definition */}
|
||||
{item.signature && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
Definition
|
||||
</h4>
|
||||
<CodeBlock
|
||||
code={item.kind === 'interface' ? `interface ${item.name} ${item.signature}` : `${item.kind} ${item.name} = ${item.signature}`}
|
||||
language="typescript"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interface Properties */}
|
||||
{item.kind === 'interface' && item.parameters && item.parameters.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
Properties
|
||||
</h4>
|
||||
<div className="overflow-x-auto rounded-lg border-2" style={{ borderColor: colors.borders.default }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b-2" style={{ borderColor: colors.borders.default, backgroundColor: colors.backgrounds.card }}>
|
||||
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Property
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{item.parameters.map((prop, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className="border-b last:border-0"
|
||||
style={{ borderColor: colors.borders.subtle }}
|
||||
>
|
||||
<td className="px-4 py-3 font-mono" style={{ color: colors.text.secondary }}>
|
||||
{prop.name}
|
||||
{prop.optional && (
|
||||
<span style={{ color: colors.text.disabled }}>?</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<TypeLink type={prop.type} className="text-xs" availableTypeIds={availableTypeIds} />
|
||||
</td>
|
||||
<td className="px-4 py-3" style={{ color: colors.text.body }}>
|
||||
{prop.description || '—'}
|
||||
{prop.defaultValue && (
|
||||
<div className="mt-1 text-xs" style={{ color: colors.text.disabled }}>
|
||||
Default: <code>{prop.defaultValue}</code>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Examples */}
|
||||
{item.examples && item.examples.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
Examples
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{item.examples.map((example, index) => (
|
||||
<CodeBlock
|
||||
key={index}
|
||||
code={example.code}
|
||||
language={example.language}
|
||||
showLineNumbers
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(surfaces.badge.muted)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* See Also */}
|
||||
{item.see && item.see.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
|
||||
See Also
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{item.see.map((ref, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm"
|
||||
style={{ color: colors.text.body }}
|
||||
>
|
||||
{ref}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
components/docs/TypeLink.tsx
Normal file
126
components/docs/TypeLink.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
'use client'
|
||||
|
||||
import { colors, effects } from '@/lib/theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TypeLinkProps {
|
||||
type: string
|
||||
className?: string
|
||||
availableTypeIds?: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a type string and converts type references into clickable links
|
||||
* that scroll to the corresponding type definition in the documentation.
|
||||
*
|
||||
* Supports:
|
||||
* - Simple types: Domain, User, etc.
|
||||
* - Generic types: Array<Domain>, Promise<User>
|
||||
* - Union types: string | number
|
||||
* - Complex types: Record<string, Domain>
|
||||
*/
|
||||
export default function TypeLink({ type, className, availableTypeIds }: TypeLinkProps) {
|
||||
const parseTypeString = (typeStr: string): React.ReactNode[] => {
|
||||
const parts: React.ReactNode[] = []
|
||||
let currentIndex = 0
|
||||
|
||||
const typeNamePattern = /\b([A-Z][a-zA-Z0-9]*)\b/g
|
||||
const builtInTypes = new Set([
|
||||
'string', 'number', 'boolean', 'void', 'null', 'undefined', 'any', 'unknown',
|
||||
'never', 'object', 'symbol', 'bigint', 'Array', 'Promise', 'Record', 'Partial',
|
||||
'Required', 'Readonly', 'Pick', 'Omit', 'Exclude', 'Extract', 'NonNullable',
|
||||
'ReturnType', 'InstanceType', 'ThisType', 'Parameters', 'ConstructorParameters',
|
||||
'Date', 'Error', 'RegExp', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Function',
|
||||
'ReadonlyArray', 'String', 'Number', 'Boolean', 'Symbol', 'Object'
|
||||
])
|
||||
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = typeNamePattern.exec(typeStr)) !== null) {
|
||||
const typeName = match[1]
|
||||
const matchStart = match.index
|
||||
const matchEnd = typeNamePattern.lastIndex
|
||||
|
||||
if (matchStart > currentIndex) {
|
||||
parts.push(
|
||||
<span key={`text-${currentIndex}`}>
|
||||
{typeStr.substring(currentIndex, matchStart)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (builtInTypes.has(typeName)) {
|
||||
parts.push(
|
||||
<span key={`builtin-${matchStart}`}>
|
||||
{typeName}
|
||||
</span>
|
||||
)
|
||||
} else {
|
||||
// Check if this type exists in the documentation
|
||||
const typeExists = availableTypeIds?.has(typeName) ?? false
|
||||
|
||||
if (typeExists) {
|
||||
parts.push(
|
||||
<button
|
||||
key={`link-${matchStart}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
const targetId = typeName
|
||||
const element = document.getElementById(targetId)
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
|
||||
element.classList.add('ring-2', 'ring-blue-400', 'ring-offset-2', 'ring-offset-gray-900')
|
||||
setTimeout(() => {
|
||||
element.classList.remove('ring-2', 'ring-blue-400', 'ring-offset-2', 'ring-offset-gray-900')
|
||||
}, 2000)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'hover:underline cursor-pointer',
|
||||
effects.transitions.colors
|
||||
)}
|
||||
style={{
|
||||
color: colors.accents.link,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = colors.accents.linkHover
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = colors.accents.link
|
||||
}}
|
||||
>
|
||||
{typeName}
|
||||
</button>
|
||||
)
|
||||
} else {
|
||||
// Type doesn't exist in docs, render as plain text
|
||||
parts.push(
|
||||
<span key={`text-${matchStart}`}>
|
||||
{typeName}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
currentIndex = matchEnd
|
||||
}
|
||||
|
||||
if (currentIndex < typeStr.length) {
|
||||
parts.push(
|
||||
<span key={`text-${currentIndex}`}>
|
||||
{typeStr.substring(currentIndex)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn('font-mono', className)}>
|
||||
{parseTypeString(type)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
98
components/domains/DomainCard.tsx
Normal file
98
components/domains/DomainCard.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
getExpirationDate,
|
||||
getDaysUntilExpiration,
|
||||
getOwnershipDuration,
|
||||
getOwnershipMonths,
|
||||
isExpiringSoon,
|
||||
formatDate,
|
||||
getNextRenewalDate
|
||||
} from '@/lib/domains/utils'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
import type { DomainCardProps } from '@/lib/types'
|
||||
import { domainVisualConfig } from '@/lib/domains/config'
|
||||
|
||||
export default function DomainCard({ domain }: DomainCardProps) {
|
||||
const expirationDate = getExpirationDate(domain)
|
||||
const nextRenewalDate = getNextRenewalDate(domain)
|
||||
const daysUntilExpiration = getDaysUntilExpiration(domain)
|
||||
const ownershipYears = getOwnershipDuration(domain)
|
||||
const ownershipMonths = getOwnershipMonths(domain)
|
||||
const expiringSoon = isExpiringSoon(domain)
|
||||
const statusVisual = domainVisualConfig.status[domain.status]
|
||||
const categoryVisual = domainVisualConfig.category[domain.category]
|
||||
const StatusIcon = statusVisual.icon
|
||||
|
||||
return (
|
||||
<Link href={`/domains/${domain.domain}`}>
|
||||
<div className="group relative h-full bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl hover:border-gray-700 transition-all hover:shadow-xl hover:shadow-black/20 cursor-pointer overflow-hidden flex flex-col">
|
||||
{expiringSoon && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gray-500"></div>
|
||||
)}
|
||||
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`${statusVisual.color}`}>
|
||||
<StatusIcon className="w-4 h-4" />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-100 group-hover:text-white transition-colors">
|
||||
{domain.domain}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 line-clamp-2 min-h-[2.5rem]">{domain.usage}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-gray-400 transition-all group-hover:translate-x-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400 mb-3">
|
||||
<span className={`${categoryVisual.color} font-medium uppercase tracking-wide`}>
|
||||
{categoryVisual.label}
|
||||
</span>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span>{domain.registrar}</span>
|
||||
{domain.autoRenew && (
|
||||
<>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span className="text-slate-500/80">Auto-renew</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-3 border-t border-gray-800/50 mt-auto">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className="text-gray-400">
|
||||
{ownershipYears < 1 ? `${ownershipMonths}mo owned` : `${ownershipYears}y owned`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className={expiringSoon ? 'text-gray-300 font-medium' : 'text-gray-400'}>
|
||||
{expiringSoon ? `${daysUntilExpiration}d left` : formatDate(expirationDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className="text-gray-400">
|
||||
Next renewal: {formatDate(nextRenewalDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
180
components/domains/DomainDetails.tsx
Normal file
180
components/domains/DomainDetails.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import {
|
||||
getRegistrationDate,
|
||||
getExpirationDate,
|
||||
getDaysUntilExpiration,
|
||||
getOwnershipDuration,
|
||||
getOwnershipMonths,
|
||||
formatDate,
|
||||
isExpiringSoon,
|
||||
getRenewalProgress,
|
||||
getOwnershipDays
|
||||
} from '@/lib/domains/utils'
|
||||
import { registrars } from '@/lib/domains/data'
|
||||
import { domainVisualConfig } from '@/lib/domains/config'
|
||||
import {
|
||||
Shield,
|
||||
Tag,
|
||||
AlertCircle,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
Activity
|
||||
} from 'lucide-react'
|
||||
import type { DomainDetailsProps } from '@/lib/types'
|
||||
|
||||
export default function DomainDetails({ domain }: DomainDetailsProps) {
|
||||
const registrationDate = getRegistrationDate(domain)
|
||||
const expirationDate = getExpirationDate(domain)
|
||||
const daysUntilExpiration = getDaysUntilExpiration(domain)
|
||||
const ownershipYears = getOwnershipDuration(domain)
|
||||
const ownershipMonths = getOwnershipMonths(domain)
|
||||
const ownershipDays = getOwnershipDays(domain)
|
||||
const expiringSoon = isExpiringSoon(domain)
|
||||
const renewalProgress = getRenewalProgress(domain)
|
||||
const registrarConfig = registrars[domain.registrar]
|
||||
const statusVisual = domainVisualConfig.status[domain.status]
|
||||
const categoryVisual = domainVisualConfig.category[domain.category]
|
||||
const StatusIcon = statusVisual.icon
|
||||
const CategoryIcon = categoryVisual.icon
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">Status</p>
|
||||
<div className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg ${statusVisual.bg} ${statusVisual.border} border`}>
|
||||
<span className={statusVisual.color}>
|
||||
<StatusIcon className="w-5 h-5" />
|
||||
</span>
|
||||
<span className={`font-medium ${statusVisual.color}`}>
|
||||
{statusVisual.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">Category</p>
|
||||
<div className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg ${categoryVisual.bg} ${categoryVisual.border} border`}>
|
||||
<span className={categoryVisual.color}>
|
||||
<CategoryIcon className="w-5 h-5" />
|
||||
</span>
|
||||
<span className={`font-medium ${categoryVisual.color}`}>
|
||||
{categoryVisual.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Domain Lifecycle</h3>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
Owned for {ownershipDays} days ({ownershipYears < 1 ? `${ownershipMonths} months` : `${ownershipYears} years`})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-2">
|
||||
<span>Registered</span>
|
||||
<span>Expires</span>
|
||||
</div>
|
||||
<div className="relative h-4 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-slate-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${renewalProgress}%` }}
|
||||
/>
|
||||
{expiringSoon && (
|
||||
<div className="absolute right-0 top-0 h-full w-24 bg-gray-600/30" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs mt-2">
|
||||
<span className="text-gray-400">{formatDate(registrationDate)}</span>
|
||||
<span className={`font-medium ${
|
||||
expiringSoon ? 'text-gray-300' : renewalProgress > 75 ? 'text-slate-400' : 'text-gray-400'
|
||||
}`}>
|
||||
{formatDate(expirationDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-center">
|
||||
<div className="p-2 bg-gray-800/50 rounded-lg">
|
||||
<div className="text-lg font-bold text-slate-400">{Math.floor(renewalProgress)}%</div>
|
||||
<div className="text-xs text-gray-500">Period Used</div>
|
||||
</div>
|
||||
<div className={`p-2 rounded-lg ${expiringSoon ? 'bg-gray-800/70' : 'bg-gray-800/50'}`}>
|
||||
<div className={`text-lg font-bold ${expiringSoon ? 'text-gray-300' : 'text-slate-400'}`}>
|
||||
{daysUntilExpiration}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Days Left</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expiringSoon && (
|
||||
<div className="flex items-center gap-2 p-3 mt-3 bg-gray-800/50 border border-gray-700 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-400">
|
||||
Domain expires soon
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="w-4 h-4 text-gray-500" />
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">Registrar</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{registrarConfig && (
|
||||
<div className={`w-8 h-8 bg-gray-800 rounded-lg flex items-center justify-center ${registrarConfig.color}`}>
|
||||
<registrarConfig.icon className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-gray-200 font-medium">{domain.registrar}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="w-4 h-4 text-gray-500" />
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">Auto-Renewal</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 cursor-default">
|
||||
{domain.autoRenew ? (
|
||||
<>
|
||||
<ToggleRight className="w-5 h-5 text-slate-400" />
|
||||
<span className="text-sm text-slate-400 font-medium">Enabled</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToggleLeft className="w-5 h-5 text-gray-500" />
|
||||
<span className="text-sm text-gray-500 font-medium">Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Tag className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-medium text-gray-300">Tags</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{domain.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1.5 bg-gray-800/50 text-gray-300 rounded-full text-sm hover:bg-gray-800 transition-colors border border-gray-700/50"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
components/domains/DomainFilters.tsx
Normal file
198
components/domains/DomainFilters.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Search, Filter, X } from 'lucide-react'
|
||||
import type {
|
||||
DomainFiltersProps,
|
||||
DomainCategory,
|
||||
DomainStatus,
|
||||
DomainRegistrarId,
|
||||
DomainSortOption
|
||||
} from '@/lib/types'
|
||||
import { sortOptions } from '@/lib/domains/config'
|
||||
|
||||
export default function DomainFilters({
|
||||
onSearchChange,
|
||||
onCategoryChange,
|
||||
onStatusChange,
|
||||
onRegistrarChange,
|
||||
onSortChange,
|
||||
registrars
|
||||
}: DomainFiltersProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCategories, setSelectedCategories] = useState<DomainCategory[]>([])
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<DomainStatus[]>([])
|
||||
const [selectedRegistrars, setSelectedRegistrars] = useState<DomainRegistrarId[]>([])
|
||||
const [sortBy, setSortBy] = useState<DomainSortOption>('name')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
const categories: DomainCategory[] = ['personal', 'service', 'project', 'fun', 'legacy']
|
||||
const statuses: DomainStatus[] = ['active', 'parked', 'reserved']
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value)
|
||||
onSearchChange(value)
|
||||
}
|
||||
|
||||
const toggleCategory = (category: DomainCategory) => {
|
||||
const updated = selectedCategories.includes(category)
|
||||
? selectedCategories.filter(c => c !== category)
|
||||
: [...selectedCategories, category]
|
||||
setSelectedCategories(updated)
|
||||
onCategoryChange(updated)
|
||||
}
|
||||
|
||||
const toggleStatus = (status: DomainStatus) => {
|
||||
const updated = selectedStatuses.includes(status)
|
||||
? selectedStatuses.filter(s => s !== status)
|
||||
: [...selectedStatuses, status]
|
||||
setSelectedStatuses(updated)
|
||||
onStatusChange(updated)
|
||||
}
|
||||
|
||||
const toggleRegistrar = (registrar: DomainRegistrarId) => {
|
||||
const updated = selectedRegistrars.includes(registrar)
|
||||
? selectedRegistrars.filter(r => r !== registrar)
|
||||
: [...selectedRegistrars, registrar]
|
||||
setSelectedRegistrars(updated)
|
||||
onRegistrarChange(updated)
|
||||
}
|
||||
|
||||
const handleSortChange = (value: DomainSortOption) => {
|
||||
setSortBy(value)
|
||||
onSortChange(value)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('')
|
||||
setSelectedCategories([])
|
||||
setSelectedStatuses([])
|
||||
setSelectedRegistrars([])
|
||||
setSortBy('name')
|
||||
onSearchChange('')
|
||||
onCategoryChange([])
|
||||
onStatusChange([])
|
||||
onRegistrarChange([])
|
||||
onSortChange('name')
|
||||
}
|
||||
|
||||
const hasActiveFilters = search || selectedCategories.length > 0 || selectedStatuses.length > 0 || selectedRegistrars.length > 0
|
||||
|
||||
return (
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Search domains..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-900/50 border border-gray-800 rounded-lg text-gray-200 placeholder-gray-500 focus:outline-none focus:border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-colors ${
|
||||
showFilters || hasActiveFilters
|
||||
? 'bg-gray-800 border-gray-700 text-white'
|
||||
: 'bg-gray-900/50 border-gray-800 text-gray-400 hover:border-gray-700 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
Filters
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-slate-500/20 text-slate-400 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => handleSortChange(e.target.value as DomainSortOption)}
|
||||
className="px-4 py-2 bg-gray-900/50 border border-gray-800 rounded-lg text-gray-200 focus:outline-none focus:border-gray-700"
|
||||
>
|
||||
{sortOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="p-4 bg-gray-900/30 border border-gray-800 rounded-lg space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium text-gray-300">Filter Options</h3>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-xs text-gray-500 hover:text-gray-400 flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs text-gray-500 mb-2">Category</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => toggleCategory(category)}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
selectedCategories.includes(category)
|
||||
? 'bg-slate-500/20 text-slate-400 border-slate-500/40'
|
||||
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs text-gray-500 mb-2">Status</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statuses.map(status => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => toggleStatus(status)}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
selectedStatuses.includes(status)
|
||||
? 'bg-slate-500/20 text-slate-400 border-slate-500/40'
|
||||
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs text-gray-500 mb-2">Registrar</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{registrars.map(registrar => (
|
||||
<button
|
||||
key={registrar}
|
||||
onClick={() => toggleRegistrar(registrar)}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
selectedRegistrars.includes(registrar)
|
||||
? 'bg-slate-500/20 text-slate-400 border-slate-500/40'
|
||||
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{registrar}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
components/domains/DomainTimeline.tsx
Normal file
88
components/domains/DomainTimeline.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { getRenewalTimeline, formatDate, getNextRenewalDate } from '@/lib/domains/utils'
|
||||
import { Calendar, RefreshCw, Star } from 'lucide-react'
|
||||
import type { DomainTimelineProps } from '@/lib/types'
|
||||
|
||||
export default function DomainTimeline({ domain }: DomainTimelineProps) {
|
||||
const timeline = getRenewalTimeline(domain)
|
||||
const nextRenewalDate = getNextRenewalDate(domain)
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-6">
|
||||
<div className="relative">
|
||||
<div className="absolute left-6 top-8 bottom-0 w-0.5 bg-gray-700"></div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{timeline.map((event, index) => {
|
||||
const isLatest = index === timeline.length - 1
|
||||
const isRegistration = event.type === 'registration'
|
||||
|
||||
return (
|
||||
<div key={index} className="relative flex items-start gap-4">
|
||||
<div className={`relative z-10 flex items-center justify-center w-12 h-12 rounded-full ${
|
||||
isRegistration || isLatest
|
||||
? 'bg-gray-800 border-2 border-slate-400/50'
|
||||
: 'bg-gray-800 border-2 border-gray-700'
|
||||
}`}>
|
||||
{isRegistration ? (
|
||||
<Star className="w-6 h-6 text-slate-300" />
|
||||
) : (
|
||||
<RefreshCw className={`w-5 h-5 ${isLatest ? 'text-slate-300' : 'text-gray-500'}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pb-8">
|
||||
<div className={`rounded-lg p-4 border transition-colors ${
|
||||
isRegistration || isLatest
|
||||
? 'bg-gray-800/50 border-gray-700/50 hover:border-gray-600/50'
|
||||
: 'bg-slate-400/5 border-slate-400/20 hover:border-slate-400/30'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className={`text-sm font-medium ${
|
||||
isRegistration || isLatest ? 'text-slate-300' : 'text-gray-400'
|
||||
}`}>
|
||||
{isRegistration ? 'Domain Registered' : 'Domain Renewed'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatDate(event.date)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-300">
|
||||
{isRegistration ? (
|
||||
<span>Initial registration for {event.years} {event.years === 1 ? 'year' : 'years'}</span>
|
||||
) : (
|
||||
<span>Renewed for {event.years} {event.years === 1 ? 'year' : 'years'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="relative z-10 flex items-center justify-center w-12 h-12 rounded-full bg-gray-900 border-2 border-dashed border-gray-700">
|
||||
<Calendar className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-gray-900/30 rounded-lg p-4 border border-dashed border-gray-700/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-500">
|
||||
Next Renewal
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatDate(nextRenewalDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{domain.autoRenew ? 'Auto-renewal enabled' : 'Manual renewal required'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
components/icons/DynadotIcon.tsx
Normal file
18
components/icons/DynadotIcon.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react'
|
||||
|
||||
interface DynadotIconProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function DynadotIcon({ className }: DynadotIconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 850 968"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M0,718.435l0,-34.062c6.563,-84.813 48.281,-160.781 117.188,-210.219c12.031,-8.656 31.874,-19.969 59.531,-33.937c17.687,-8.938 37.969,-19.438 60.812,-31.532c0.532,-0.281 0.719,-0.937 0.438,-1.468l-87.531,-168.063c-0.438,-0.844 -0.094,-1.906 0.75,-2.344c186.406,-96.906 318.687,-165.656 396.875,-206.281c96.187,-49.937 216.875,-37.875 301.843,30.781c135.438,109.469 128.219,320.375 -11.468,424.25c-13.094,9.719 -34.344,22.094 -63.688,37.125c-35.875,18.344 -57.781,29.657 -65.719,33.907c-0.656,0.343 -0.906,1.156 -0.562,1.781l87.375,167.812c0.437,0.844 0.094,1.907 -0.75,2.344c-224.313,116.531 -345.219,179.469 -362.719,188.813c-39.594,21.093 -67.937,34.093 -84.969,38.968c-167.312,47.75 -333.031,-64.437 -347.406,-237.875Zm330.094,-357.75c2,-0.812 18.75,-9.593 50.25,-26.312c24.219,-12.875 41.875,-20.781 52.969,-23.719c61.593,-16.375 124.468,2.031 166.937,49.438c9.25,10.312 19.969,27.5 32.156,51.5c14.282,28.187 23.625,46.187 27.938,54c0.437,0.781 1.437,1.062 2.219,0.625c27.125,-14.438 47.781,-25.188 61.937,-32.25c29.031,-14.563 48.969,-26.469 59.75,-35.688c60.219,-51.594 79.594,-136.937 42.781,-207.562c-36,-69.094 -113.843,-105.344 -190.093,-86.969c-13.407,3.219 -33.532,11.844 -60.375,25.906c-94.157,49.313 -190.344,99.469 -288.625,150.5c-0.844,0.469 -1.188,1.531 -0.719,2.375l40.406,77.281c0.469,0.907 1.563,1.282 2.469,0.875Zm220.031,122.032c0,-42.5 -34.469,-76.969 -76.969,-76.969c-42.5,-0 -76.968,34.469 -76.968,76.969c-0,42.5 34.468,76.968 76.968,76.968c42.5,0 76.969,-34.468 76.969,-76.968Zm66.281,122.187c-2,0.813 -18.781,9.563 -50.375,26.25c-24.281,12.844 -42,20.75 -53.093,23.688c-61.719,16.281 -124.657,-2.25 -167.094,-49.813c-9.25,-10.344 -19.969,-27.562 -32.125,-51.625c-14.281,-28.25 -23.563,-46.281 -27.906,-54.125c-0.438,-0.781 -1.438,-1.094 -2.219,-0.656c-27.188,14.406 -47.875,25.156 -62.063,32.219c-29.125,14.531 -49.094,26.406 -59.906,35.625c-60.406,51.562 -79.969,137 -43.219,207.812c35.938,69.25 113.844,105.688 190.25,87.438c13.406,-3.219 33.594,-11.813 60.5,-25.844c94.375,-49.219 190.813,-99.313 289.313,-150.25c0.875,-0.438 1.187,-1.5 0.75,-2.375l-40.344,-77.469c-0.469,-0.906 -1.563,-1.281 -2.469,-0.875Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
12
components/icons/GoogleIcon.tsx
Normal file
12
components/icons/GoogleIcon.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react'
|
||||
import { SiGoogle } from 'react-icons/si'
|
||||
|
||||
interface GoogleIconProps {
|
||||
className?: string
|
||||
strokeWidth?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export default function GoogleIcon({ className, size }: GoogleIconProps) {
|
||||
return <SiGoogle className={className} size={size} />
|
||||
}
|
||||
25
components/icons/KowalskiIcon.tsx
Normal file
25
components/icons/KowalskiIcon.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react'
|
||||
|
||||
interface KowalskiIconProps {
|
||||
className?: string
|
||||
strokeWidth?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export default function KowalskiIcon({ className, size = 24 }: KowalskiIconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 400 400"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
>
|
||||
<path d="M179.297 50.376 C 164.092 53.168,147.855 61.349,131.479 74.468 C 113.775 88.651,105.218 103.233,92.361 141.126 C 78.387 182.313,68.874 223.118,48.583 328.906 C 42.461 360.825,38.004 394.166,39.214 398.989 L 39.468 400.000 218.765 400.000 L 398.062 400.000 397.809 398.926 C 395.000 386.997,393.091 381.753,389.221 375.330 C 386.867 371.423,384.640 368.274,373.310 352.842 C 365.699 342.475,359.054 331.666,347.665 311.133 C 341.277 299.615,327.304 275.792,319.140 262.500 C 301.796 234.261,299.201 227.435,298.428 208.024 C 297.409 182.454,294.676 167.052,285.498 135.156 C 278.422 110.564,269.344 94.344,254.114 79.080 C 233.735 58.655,201.519 46.295,179.297 50.376 M248.799 106.489 C 261.588 113.493,267.969 126.130,269.712 147.904 C 270.757 160.959,271.922 164.811,277.307 173.024 C 287.186 188.091,288.511 195.505,285.231 217.383 C 282.919 232.807,283.079 236.616,286.314 243.164 C 288.475 247.539,298.449 263.364,300.698 265.988 C 306.079 272.264,307.804 275.534,311.451 286.369 C 313.507 292.477,314.275 295.779,316.038 306.092 C 318.955 323.145,323.794 340.998,328.706 352.832 C 329.053 353.668,328.477 353.717,327.031 352.973 C 317.514 348.079,306.139 347.859,297.011 352.392 L 293.046 354.362 290.008 353.902 C 285.368 353.198,278.329 351.147,269.938 348.053 C 240.075 337.042,227.498 340.095,217.498 360.781 C 216.210 363.445,215.063 365.625,214.949 365.625 C 214.835 365.625,212.348 363.747,209.422 361.451 C 206.496 359.155,197.773 352.358,190.039 346.345 C 168.528 329.625,163.860 325.786,148.421 312.120 C 144.652 308.784,138.722 303.594,135.241 300.586 C 125.704 292.343,125.397 290.429,130.287 269.695 C 134.458 252.013,134.120 248.138,127.335 235.860 C 118.910 220.615,116.802 212.186,118.504 200.543 C 119.671 192.555,120.387 190.606,124.348 184.630 C 130.549 175.276,130.610 174.884,127.001 167.653 C 120.735 155.103,119.360 142.129,123.311 132.841 C 126.621 125.061,135.901 110.371,138.603 108.638 C 149.303 101.772,171.655 109.150,195.910 127.554 C 209.712 138.026,217.301 140.791,222.032 137.070 C 223.212 136.141,226.543 129.441,229.196 122.656 C 235.502 106.533,240.841 102.130,248.799 106.489 M236.340 143.691 C 230.683 149.637,232.688 170.703,238.910 170.703 C 244.798 170.703,247.446 154.748,243.048 145.766 C 241.118 141.824,238.798 141.107,236.340 143.691 M166.625 145.801 C 161.260 151.182,162.200 169.876,167.956 172.260 C 171.312 173.650,174.196 169.334,174.921 161.837 C 176.037 150.290,171.292 141.119,166.625 145.801 M210.742 166.483 C 210.313 166.727,208.139 168.661,205.912 170.780 C 192.832 183.226,177.161 190.913,152.344 197.056 C 142.302 199.542,142.081 199.608,141.269 200.331 C 138.124 203.130,139.040 206.449,145.002 213.857 C 153.978 225.011,161.812 227.818,190.234 230.066 C 204.734 231.213,213.693 232.795,230.469 237.170 C 241.058 239.932,240.906 239.903,242.542 239.463 C 245.231 238.739,245.970 237.767,249.800 229.930 C 251.807 225.822,254.812 220.352,256.478 217.773 C 274.939 189.203,275.362 186.052,260.938 184.575 C 241.342 182.569,228.672 178.037,219.653 169.807 C 215.348 165.879,213.210 165.081,210.742 166.483 M216.988 180.273 C 224.271 189.549,229.777 201.830,235.700 222.012 C 237.035 226.558,237.043 226.636,236.205 226.410 C 217.682 221.409,205.456 219.237,189.844 218.172 C 174.731 217.142,167.844 216.033,162.791 213.815 C 156.379 211.002,149.455 205.078,152.577 205.078 C 154.767 205.078,173.978 199.068,180.753 196.263 C 191.616 191.766,201.332 185.333,208.970 177.578 L 211.886 174.618 213.210 175.883 C 213.938 176.579,215.638 178.555,216.988 180.273 M241.016 188.474 C 245.324 189.716,252.551 191.076,257.715 191.617 C 259.058 191.758,260.156 191.965,260.156 192.077 C 260.156 192.456,257.282 197.041,252.129 204.883 C 246.676 213.181,243.750 217.912,243.750 218.432 C 243.750 219.784,242.931 218.154,242.193 215.332 C 239.604 205.442,234.470 192.782,230.002 185.275 L 229.259 184.026 232.891 185.603 C 234.889 186.471,238.545 187.763,241.016 188.474"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
19
components/icons/NameIcon.tsx
Normal file
19
components/icons/NameIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react'
|
||||
|
||||
interface NameIconProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function NameIcon({ className }: NameIconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="m9.7 75v-47.1l13.5-3.3-2.1 15.2h1.5c.8-3.5 2.2-6.5 3.9-9 1.8-2.4 4.1-4.3 6.9-5.6 2.9-1.3 6.1-2 9.9-2 4.9 0 9.1 1.1 12.7 3.4 3.5 2.2 6.2 5.4 8.1 9.7 2 4.2 2.9 9.2 2.9 15v23.7h-13.4v-20.2c0-3.9-.6-7.2-1.8-9.9-1.2-2.8-2.9-4.9-5.2-6.3-2.3-1.5-5-2.2-8.2-2.2-4.8 0-8.5 1.6-11.2 4.8s-4 7.7-4 13.6v20.2z"/>
|
||||
<circle cx="75" cy="68.5" r="5.7"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
310
components/navigation/Footer.tsx
Normal file
310
components/navigation/Footer.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
TbCopyrightOff,
|
||||
TbMail,
|
||||
TbBrandGithub,
|
||||
TbBrandX,
|
||||
} from "react-icons/tb"
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import RandomFooterMsg from "../objects/RandomFooterMsg"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { colors, surfaces } from '@/lib/theme'
|
||||
import { getRecentGitHubRepos } from '@/lib/github'
|
||||
import {
|
||||
footerNavigationLinks,
|
||||
footerSupportLinks,
|
||||
} from './footer-config'
|
||||
import type {
|
||||
FooterMenuRenderContext,
|
||||
FooterMenuSection,
|
||||
NavigationIcon,
|
||||
} from '@/lib/types/navigation'
|
||||
|
||||
const FOOTER_MENU_SECTIONS: FooterMenuSection[] = [
|
||||
{
|
||||
type: 'links',
|
||||
title: 'Navigation',
|
||||
links: footerNavigationLinks,
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
title: 'Latest Projects',
|
||||
render: ({ githubRepos, githubUsername }: FooterMenuRenderContext) => (
|
||||
githubRepos.length > 0
|
||||
? githubRepos.map((repo) => (
|
||||
<FooterLink
|
||||
key={repo.id}
|
||||
href={repo.url}
|
||||
icon={TbBrandGithub}
|
||||
external
|
||||
>
|
||||
<span className="truncate" title={repo.name}>
|
||||
{repo.name}
|
||||
</span>
|
||||
</FooterLink>
|
||||
))
|
||||
: (
|
||||
<FooterLink
|
||||
href={`https://github.com/${githubUsername}`}
|
||||
icon={TbBrandGithub}
|
||||
external
|
||||
>
|
||||
Projects unavailable — visit GitHub
|
||||
</FooterLink>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'links',
|
||||
title: 'Support Me',
|
||||
links: footerSupportLinks,
|
||||
},
|
||||
]
|
||||
|
||||
interface FooterLinkProps {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
external?: boolean
|
||||
icon?: NavigationIcon
|
||||
}
|
||||
|
||||
const FooterLink = ({ href, children, external = false, icon: Icon }: FooterLinkProps) => {
|
||||
const linkProps = external ? { target: "_blank", rel: "noopener noreferrer" } : {}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
{...linkProps}
|
||||
className={cn(
|
||||
"flex items-center transition-colors duration-300 group",
|
||||
"hover:text-white"
|
||||
)}
|
||||
style={{ color: colors.text.muted }}
|
||||
>
|
||||
{Icon && (
|
||||
<span className="mr-1.5 group-hover:scale-110 transition-transform">
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
{external && <ChevronRight size={14} className="ml-0.5 opacity-50 group-hover:opacity-100 transition-opacity" />}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface FooterSectionProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const FooterSection = ({ title, children }: FooterSectionProps) => (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h3
|
||||
className="font-semibold text-sm uppercase tracking-wider"
|
||||
style={{ color: colors.text.secondary }}
|
||||
>{title}</h3>
|
||||
<div className="flex flex-col space-y-2.5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
type Persona = {
|
||||
role: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const personaOptions: Persona[] = [
|
||||
{
|
||||
role: 'Chief Synergy Evangelist',
|
||||
description: 'Drives enterprise-wide alignment through scalable cross-functional touchpoints.'
|
||||
},
|
||||
{
|
||||
role: 'Director of Strategic Buzzwords',
|
||||
description: 'Operationalizes high-impact vocabulary to maximize stakeholder resonance.'
|
||||
},
|
||||
{
|
||||
role: 'Vice President of Change Management',
|
||||
description: 'Leads transformational roadmaps that empower teams to pivot at scale.'
|
||||
},
|
||||
{
|
||||
role: 'Global KPI Whisperer',
|
||||
description: 'Ensures metric integrity through proactive dashboard storytelling.'
|
||||
},
|
||||
{
|
||||
role: 'Head of Agile Communications',
|
||||
description: 'Facilitates sprint cadence narratives for executive-level consumption.'
|
||||
},
|
||||
{
|
||||
role: 'VP of Continuous Optimization',
|
||||
description: 'Champions always-on iteration loops to unlock compounding efficiency gains.'
|
||||
},
|
||||
{
|
||||
role: 'Principal Narrative Architect',
|
||||
description: 'Synthesizes cross-team input into unified, board-ready success frameworks.'
|
||||
},
|
||||
{
|
||||
role: 'Lead Alignment Strategist',
|
||||
description: 'Converts strategic pivots into measurable OKR cascades and culture moments.'
|
||||
},
|
||||
{
|
||||
role: 'Chief Risk Mitigator',
|
||||
description: 'De-risks enterprise bets through proactive dependency orchestration.'
|
||||
},
|
||||
{
|
||||
role: 'Director of Value Realization',
|
||||
description: 'Translates initiatives into quantifiable ROI across all stakeholder tiers.'
|
||||
}
|
||||
]
|
||||
|
||||
const defaultPersona: Persona = personaOptions[0] ?? {
|
||||
role: 'Developer & Creator',
|
||||
description: 'Building thoughtful digital experiences and exploring the intersection of technology, music, and creativity. Currently focused on web development and AI integration.'
|
||||
}
|
||||
|
||||
const getPersonaByIndex = (index: number | undefined): Persona => {
|
||||
if (!personaOptions.length) {
|
||||
return defaultPersona
|
||||
}
|
||||
|
||||
if (typeof index !== 'number' || Number.isNaN(index)) {
|
||||
return defaultPersona
|
||||
}
|
||||
|
||||
const safeIndex = ((Math.floor(index) % personaOptions.length) + personaOptions.length) % personaOptions.length
|
||||
return personaOptions[safeIndex] ?? defaultPersona
|
||||
}
|
||||
|
||||
interface FooterProps {
|
||||
footerMessageIndex?: number
|
||||
}
|
||||
|
||||
export default async function Footer({ footerMessageIndex }: FooterProps) {
|
||||
const persona = getPersonaByIndex(footerMessageIndex)
|
||||
const { username: githubUsername, repos: githubRepos } = await getRecentGitHubRepos()
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={cn(surfaces.panel.overlay, "mt-auto border-t")}
|
||||
style={{ color: colors.text.muted }}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-[1.2fr_repeat(3,minmax(0,1fr))] gap-x-10 gap-y-12 lg:gap-x-16">
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-1">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div
|
||||
className="relative w-16 h-16 rounded-full overflow-hidden ring-2"
|
||||
style={{
|
||||
backgroundColor: colors.borders.default,
|
||||
borderColor: colors.borders.hover
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/ihatenodejs.jpg"
|
||||
alt="Aidan"
|
||||
width={64}
|
||||
height={64}
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
className="font-bold text-lg"
|
||||
style={{ color: colors.text.primary }}
|
||||
>Aidan</h3>
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: colors.text.muted }}
|
||||
>{persona.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-sm leading-relaxed"
|
||||
style={{ color: colors.text.muted }}
|
||||
>{persona.description}</p>
|
||||
|
||||
<div className="flex items-center space-x-4 pt-2">
|
||||
<Link
|
||||
href={`https://github.com/${githubUsername}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-white transition-colors"
|
||||
style={{ color: colors.text.muted }}
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<TbBrandGithub size={20} />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://x.com/aidxnn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-white transition-colors"
|
||||
style={{ color: colors.text.muted }}
|
||||
aria-label="X (Twitter)"
|
||||
>
|
||||
<TbBrandX size={20} />
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="hover:text-white transition-colors"
|
||||
style={{ color: colors.text.muted }}
|
||||
aria-label="Email"
|
||||
>
|
||||
<TbMail size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{FOOTER_MENU_SECTIONS.map((section) => (
|
||||
<FooterSection key={section.title} title={section.title}>
|
||||
{section.type === 'links'
|
||||
? section.links.map(({ href, label, icon, external }) => (
|
||||
<FooterLink key={href} href={href} icon={icon} external={external}>
|
||||
{label}
|
||||
</FooterLink>
|
||||
))
|
||||
: section.render({ githubUsername, githubRepos })}
|
||||
</FooterSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="border-t"
|
||||
style={{
|
||||
borderColor: colors.borders.muted,
|
||||
backgroundColor: colors.backgrounds.card
|
||||
}}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto_1fr] items-center gap-y-2">
|
||||
<div
|
||||
className="flex items-center justify-center sm:justify-start text-sm"
|
||||
style={{ color: colors.text.disabled }}
|
||||
>
|
||||
<TbCopyrightOff className="mr-2" size={16} />
|
||||
<span>Open Source and Copyright-Free</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2 text-sm">
|
||||
<RandomFooterMsg index={footerMessageIndex} />
|
||||
</div>
|
||||
|
||||
{/* soon ->
|
||||
<div className="flex items-center justify-center sm:justify-end space-x-4 text-sm">
|
||||
<span className="flex items-center">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||
<span style={{ color: colors.text.disabled }}>All Systems Operational</span>
|
||||
</span>
|
||||
</div>*/}<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
495
components/navigation/Header.tsx
Normal file
495
components/navigation/Header.tsx
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
"use client"
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
X,
|
||||
Menu,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { colors, surfaces } from '@/lib/theme'
|
||||
import type {
|
||||
NavigationIcon,
|
||||
NavigationMenuItem,
|
||||
NavigationDropdownConfig,
|
||||
NavigationDropdownGroup,
|
||||
} from '@/lib/types/navigation'
|
||||
import { headerNavigationConfig } from './header-config'
|
||||
|
||||
const NAVIGATION_CONFIG: NavigationMenuItem[] = headerNavigationConfig
|
||||
|
||||
interface NavItemProps {
|
||||
href: string
|
||||
icon: NavigationIcon
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const NavItem = ({ href, icon, children }: NavItemProps) => (
|
||||
<div className="nav-item">
|
||||
<Link href={href} className={cn("flex items-center", surfaces.button.nav)}>
|
||||
{React.createElement(icon, { className: "text-md mr-2", strokeWidth: 2.5, size: 20 })}
|
||||
{children}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface DropdownNavItemProps {
|
||||
id: string
|
||||
href: string
|
||||
icon: NavigationIcon
|
||||
children: React.ReactNode
|
||||
dropdownContent: React.ReactNode
|
||||
isMobile?: boolean
|
||||
isOpen?: boolean
|
||||
onOpenChange?: (id: string | null, immediate?: boolean) => void
|
||||
}
|
||||
|
||||
const DropdownNavItem = ({ id, href, icon, children, dropdownContent, isMobile = false, isOpen = false, onOpenChange }: DropdownNavItemProps) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
onOpenChange?.(null, true);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile && isOpen) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [isMobile, isOpen, onOpenChange]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile) {
|
||||
onOpenChange?.(id, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
if (!isMobile) {
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (relatedTarget instanceof Node && dropdownRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
onOpenChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isMobile) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenChange?.(isOpen ? null : id, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="nav-item relative"
|
||||
ref={dropdownRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
onClick={isMobile ? handleClick : undefined}
|
||||
className={cn("flex items-center justify-between w-full", surfaces.button.nav)}
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
{React.createElement(icon, { className: "text-md mr-2", strokeWidth: 2.5, size: 20 })}
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
<ChevronDown className={`ml-2 transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} strokeWidth={2.5} size={16} />
|
||||
</Link>
|
||||
{isOpen && (
|
||||
<>
|
||||
{!isMobile && <div className="absolute left-0 top-full w-full h-1 z-50" />}
|
||||
<div className={isMobile ? 'relative w-full mt-2 ml-5 pr-4' : 'absolute left-0 mt-1 z-50 flex'}>
|
||||
{dropdownContent}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NestedDropdownItemProps {
|
||||
children: React.ReactNode
|
||||
nestedContent: React.ReactNode
|
||||
icon: NavigationIcon
|
||||
isMobile?: boolean
|
||||
itemKey: string
|
||||
activeNested: string | null
|
||||
onNestedChange: (key: string | null, immediate?: boolean) => void
|
||||
}
|
||||
|
||||
const NestedDropdownItem = ({ children, nestedContent, icon: Icon, isMobile = false, itemKey, activeNested, onNestedChange }: NestedDropdownItemProps) => {
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const isOpen = activeNested === itemKey;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile) {
|
||||
onNestedChange(itemKey, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
if (!isMobile) {
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (relatedTarget instanceof Node && itemRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
onNestedChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isMobile) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onNestedChange(isOpen ? null : itemKey, true);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
ref={itemRef}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn("flex items-center justify-between w-full text-left px-4 py-3 text-sm", surfaces.button.dropdownItem)}
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
<Icon className="mr-3" strokeWidth={2.5} size={18} />
|
||||
{children}
|
||||
</span>
|
||||
<ChevronRight className={`transform transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`} strokeWidth={2.5} size={18} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="relative mt-2 ml-5 pr-4 space-y-1">
|
||||
{nestedContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
ref={itemRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full text-left px-4 py-3 text-sm",
|
||||
isOpen ? "bg-gray-700/40 text-white" : surfaces.button.dropdownItem
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
<Icon className="mr-3" strokeWidth={2.5} size={18} />
|
||||
{children}
|
||||
</span>
|
||||
<ChevronDown className={`transform transition-transform duration-200 ${isOpen ? '-rotate-90' : ''}`} strokeWidth={2.5} size={18} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="absolute left-full top-0 w-4 h-full z-50" />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-full top-0 ml-1 w-64 z-50",
|
||||
"animate-in fade-in-0 zoom-in-95 slide-in-from-left-2 duration-200",
|
||||
surfaces.panel.dropdown
|
||||
)}
|
||||
onMouseEnter={() => onNestedChange(itemKey, true)}
|
||||
onMouseLeave={(e) => {
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (relatedTarget instanceof Node && itemRef.current?.contains(relatedTarget)) return;
|
||||
onNestedChange(null);
|
||||
}}
|
||||
>
|
||||
{nestedContent}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNestedGroups = (groups: NavigationDropdownGroup[], isMobile: boolean) => {
|
||||
const hasAnyTitle = groups.some(group => group.title);
|
||||
|
||||
return (
|
||||
<div className={hasAnyTitle ? 'py-2' : ''}>
|
||||
{groups.map((group, index) => (
|
||||
<div key={group.title || `group-${index}`}>
|
||||
{group.title && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-[11px] uppercase tracking-wide",
|
||||
isMobile ? 'px-4 pt-1 pb-2' : 'px-5 pt-2 pb-2'
|
||||
)}
|
||||
style={{ color: colors.text.muted }}
|
||||
>
|
||||
{group.title}
|
||||
</div>
|
||||
)}
|
||||
{group.links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={cn(
|
||||
"flex items-center text-sm",
|
||||
isMobile ? 'px-4 py-2.5' : 'px-5 py-3',
|
||||
surfaces.button.dropdownItem
|
||||
)}
|
||||
{...(link.external && { target: '_blank', rel: 'noopener noreferrer' })}
|
||||
>
|
||||
{React.createElement(link.icon, { className: 'mr-3', strokeWidth: 2.5, size: 18 })}
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderDropdownContent = (config: NavigationDropdownConfig, isMobile: boolean, activeNested: string | null, onNestedChange: (key: string | null, immediate?: boolean) => void) => (
|
||||
<div className={cn(isMobile ? 'w-full' : cn('w-64', surfaces.panel.dropdown))}>
|
||||
{config.items.map((item) => {
|
||||
if (item.type === 'link') {
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center px-4 text-sm",
|
||||
isMobile ? 'py-2.5' : 'py-3',
|
||||
surfaces.button.dropdownItem
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!isMobile && activeNested) {
|
||||
onNestedChange(null, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{React.createElement(item.icon, { className: 'mr-3', strokeWidth: 2.5, size: 18 })}
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NestedDropdownItem
|
||||
key={`nested-${item.label}`}
|
||||
itemKey={`nested-${item.label}`}
|
||||
icon={item.icon}
|
||||
isMobile={isMobile}
|
||||
activeNested={activeNested}
|
||||
onNestedChange={onNestedChange}
|
||||
nestedContent={renderNestedGroups(item.groups, isMobile)}
|
||||
>
|
||||
{item.label}
|
||||
</NestedDropdownItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function Header() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
|
||||
const [activeNested, setActiveNested] = useState<string | null>(null);
|
||||
const [showDesktopOverlay, setShowDesktopOverlay] = useState(false);
|
||||
const [overlayVisible, setOverlayVisible] = useState(false);
|
||||
const overlayCloseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const overlayOpenFrameRef = useRef<number | null>(null);
|
||||
const dropdownTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const nestedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsOpen(!isOpen);
|
||||
if (isOpen) {
|
||||
setActiveDropdown(null);
|
||||
setActiveNested(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropdownChange = (id: string | null, immediate: boolean = false) => {
|
||||
if (dropdownTimeoutRef.current) {
|
||||
clearTimeout(dropdownTimeoutRef.current);
|
||||
dropdownTimeoutRef.current = null;
|
||||
}
|
||||
if (nestedTimeoutRef.current) {
|
||||
clearTimeout(nestedTimeoutRef.current);
|
||||
nestedTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (id !== null || immediate) {
|
||||
setActiveDropdown(id);
|
||||
setActiveNested(null);
|
||||
} else {
|
||||
dropdownTimeoutRef.current = setTimeout(() => {
|
||||
setActiveDropdown(null);
|
||||
setActiveNested(null);
|
||||
dropdownTimeoutRef.current = null;
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNestedChange = (key: string | null, immediate: boolean = false) => {
|
||||
if (nestedTimeoutRef.current) {
|
||||
clearTimeout(nestedTimeoutRef.current);
|
||||
nestedTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (key !== null || immediate) {
|
||||
setActiveNested(key);
|
||||
} else {
|
||||
nestedTimeoutRef.current = setTimeout(() => {
|
||||
setActiveNested(null);
|
||||
nestedTimeoutRef.current = null;
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 1024);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
if (overlayOpenFrameRef.current !== null) {
|
||||
cancelAnimationFrame(overlayOpenFrameRef.current);
|
||||
overlayOpenFrameRef.current = null;
|
||||
}
|
||||
if (overlayCloseTimeoutRef.current !== null) {
|
||||
clearTimeout(overlayCloseTimeoutRef.current);
|
||||
overlayCloseTimeoutRef.current = null;
|
||||
}
|
||||
setOverlayVisible(false);
|
||||
setShowDesktopOverlay(false);
|
||||
} else if (activeDropdown) {
|
||||
if (overlayCloseTimeoutRef.current !== null) {
|
||||
clearTimeout(overlayCloseTimeoutRef.current);
|
||||
overlayCloseTimeoutRef.current = null;
|
||||
}
|
||||
setShowDesktopOverlay(true);
|
||||
overlayOpenFrameRef.current = requestAnimationFrame(() => {
|
||||
setOverlayVisible(true);
|
||||
overlayOpenFrameRef.current = null;
|
||||
});
|
||||
} else {
|
||||
if (overlayOpenFrameRef.current !== null) {
|
||||
cancelAnimationFrame(overlayOpenFrameRef.current);
|
||||
overlayOpenFrameRef.current = null;
|
||||
}
|
||||
setOverlayVisible(false);
|
||||
overlayCloseTimeoutRef.current = setTimeout(() => {
|
||||
setShowDesktopOverlay(false);
|
||||
overlayCloseTimeoutRef.current = null;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (overlayOpenFrameRef.current !== null) {
|
||||
cancelAnimationFrame(overlayOpenFrameRef.current);
|
||||
overlayOpenFrameRef.current = null;
|
||||
}
|
||||
if (overlayCloseTimeoutRef.current !== null) {
|
||||
clearTimeout(overlayCloseTimeoutRef.current);
|
||||
overlayCloseTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeDropdown, isMobile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDesktopOverlay && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-30 pointer-events-none transition-all duration-300 opacity-0 backdrop-blur-none',
|
||||
overlayVisible && 'opacity-100 backdrop-blur-sm'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<header className={cn(surfaces.panel.overlay, "sticky top-0 z-50 border-b")}>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 backdrop-blur-md z-40 lg:hidden"
|
||||
onClick={toggleMenu}
|
||||
/>
|
||||
)}
|
||||
<nav className="container mx-auto px-4 py-4 flex justify-between items-center relative z-50">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"text-2xl font-bold transition-all duration-300 hover:glow",
|
||||
"hover:text-white"
|
||||
)}
|
||||
style={{ color: colors.text.body }}
|
||||
>
|
||||
aidan.so
|
||||
</Link>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="lg:hidden focus:outline-hidden"
|
||||
style={{ color: colors.text.body }}
|
||||
>
|
||||
{isOpen ? <X className="text-2xl" /> : <Menu className="text-2xl" />}
|
||||
</button>
|
||||
<ul className={cn(
|
||||
"flex flex-col lg:flex-row space-y-3 lg:space-y-0 lg:space-x-4",
|
||||
"absolute lg:static w-full lg:w-auto left-0 lg:left-auto top-full lg:top-auto",
|
||||
"px-2 py-4 lg:p-0 transition-all duration-300 ease-in-out z-50",
|
||||
"lg:bg-transparent",
|
||||
isOpen ? 'flex' : 'hidden lg:flex'
|
||||
)}
|
||||
style={{ backgroundColor: isMobile ? colors.backgrounds.cardSolid : undefined }}
|
||||
>
|
||||
{NAVIGATION_CONFIG.map((item) => {
|
||||
if (item.type === 'link') {
|
||||
return (
|
||||
<NavItem key={item.id} href={item.href} icon={item.icon}>
|
||||
{item.label}
|
||||
</NavItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownNavItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
href={item.href}
|
||||
icon={item.icon}
|
||||
dropdownContent={renderDropdownContent(item.dropdown, isMobile, activeNested, handleNestedChange)}
|
||||
isMobile={isMobile}
|
||||
isOpen={activeDropdown === item.id}
|
||||
onOpenChange={handleDropdownChange}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownNavItem>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
components/navigation/footer-config.ts
Normal file
47
components/navigation/footer-config.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
House,
|
||||
User,
|
||||
Phone,
|
||||
BookOpen,
|
||||
CreditCard,
|
||||
} from 'lucide-react'
|
||||
import type { NavigationLink } from '@/lib/types/navigation'
|
||||
import { SiGithubsponsors } from 'react-icons/si'
|
||||
|
||||
export const footerNavigationLinks: NavigationLink[] = [
|
||||
{
|
||||
href: '/',
|
||||
label: 'Home',
|
||||
icon: House
|
||||
},
|
||||
{
|
||||
href: '/about',
|
||||
label: 'About Me',
|
||||
icon: User
|
||||
},
|
||||
{
|
||||
href: '/contact',
|
||||
label: 'Contact',
|
||||
icon: Phone
|
||||
},
|
||||
{
|
||||
href: '/manifesto',
|
||||
label: 'Manifesto',
|
||||
icon: BookOpen
|
||||
},
|
||||
]
|
||||
|
||||
export const footerSupportLinks: NavigationLink[] = [
|
||||
{
|
||||
href: 'https://donate.stripe.com/6oEeWVcXs9L9ctW4gj',
|
||||
label: 'Donate via Stripe',
|
||||
icon: CreditCard,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/sponsors/ihatenodejs',
|
||||
label: 'GitHub Sponsors',
|
||||
icon: SiGithubsponsors,
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
165
components/navigation/header-config.ts
Normal file
165
components/navigation/header-config.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import {
|
||||
House,
|
||||
Link as LinkIcon,
|
||||
User,
|
||||
Phone,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Smartphone,
|
||||
Headphones,
|
||||
Briefcase,
|
||||
Package,
|
||||
Cloud,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { TbUserHeart } from 'react-icons/tb'
|
||||
import KowalskiIcon from '@/components/icons/KowalskiIcon'
|
||||
import GoogleIcon from '@/components/icons/GoogleIcon'
|
||||
|
||||
import type { NavigationMenuItem } from '@/lib/types/navigation'
|
||||
|
||||
export const headerNavigationConfig: NavigationMenuItem[] = [
|
||||
{
|
||||
type: 'link',
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
href: '/',
|
||||
icon: House,
|
||||
},
|
||||
{
|
||||
type: 'dropdown',
|
||||
id: 'about',
|
||||
label: 'About Me',
|
||||
href: '/about',
|
||||
icon: User,
|
||||
dropdown: {
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
label: 'Get to Know Me',
|
||||
href: '/about',
|
||||
icon: TbUserHeart,
|
||||
},
|
||||
{
|
||||
type: 'nested',
|
||||
label: 'Devices',
|
||||
icon: Smartphone,
|
||||
groups: [
|
||||
{
|
||||
title: 'Phones',
|
||||
links: [
|
||||
{
|
||||
type: 'link',
|
||||
label: 'Pixel 3a XL (bonito)',
|
||||
href: '/device/bonito',
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
label: 'Pixel 7 Pro (cheetah)',
|
||||
href: '/device/cheetah',
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
label: 'Pixel 9 Pro (komodo)',
|
||||
href: '/device/komodo',
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'DAPs',
|
||||
links: [
|
||||
{
|
||||
type: 'link',
|
||||
label: 'JM21',
|
||||
href: '/device/jm21',
|
||||
icon: Headphones,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'nested',
|
||||
label: 'Projects',
|
||||
icon: Briefcase,
|
||||
groups: [
|
||||
{
|
||||
title: '',
|
||||
links: [
|
||||
{
|
||||
type: 'link',
|
||||
label: 'modules',
|
||||
href: 'https://modules.lol/',
|
||||
icon: Package,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
label: 'Kowalski',
|
||||
href: 'https://kowalski.social/',
|
||||
icon: KowalskiIcon,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
label: 'p0ntus',
|
||||
href: 'https://p0ntus.com/',
|
||||
icon: Cloud,
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'dropdown',
|
||||
id: 'ai',
|
||||
label: 'AI',
|
||||
href: '/ai',
|
||||
icon: Brain,
|
||||
dropdown: {
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
label: 'AI Usage',
|
||||
href: '/ai/usage',
|
||||
icon: Brain,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
id: 'contact',
|
||||
label: 'Contact',
|
||||
href: '/contact',
|
||||
icon: Phone,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
id: 'domains',
|
||||
label: 'Domains',
|
||||
href: '/domains',
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
id: 'manifesto',
|
||||
label: 'Manifesto',
|
||||
href: '/manifesto',
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
href: '/docs',
|
||||
icon: FileText,
|
||||
},
|
||||
]
|
||||
4
components/navigation/index.ts
Normal file
4
components/navigation/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { default as Header } from './Header'
|
||||
export { default as Footer } from './Footer'
|
||||
export { headerNavigationConfig } from './header-config'
|
||||
export { footerNavigationLinks, footerSupportLinks } from './footer-config'
|
||||
|
|
@ -4,7 +4,7 @@ import { useEffect } from "react";
|
|||
|
||||
export default function AnimatedTitle() {
|
||||
useEffect(() => {
|
||||
const title = 'aidxn.cc';
|
||||
const title = 'aidan.so';
|
||||
let index = 1;
|
||||
let forward = true;
|
||||
const interval = setInterval(() => {
|
||||
|
|
@ -1,22 +1,42 @@
|
|||
import { default as NextLink } from 'next/link'
|
||||
import { cn } from '@/lib/theme'
|
||||
import { externalLinkProps } from '@/lib/utils/styles'
|
||||
|
||||
interface LinkProps {
|
||||
href: string
|
||||
className?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
variant?: 'default' | 'nav' | 'muted'
|
||||
external?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Link(props: LinkProps) {
|
||||
export default function Link({
|
||||
href,
|
||||
className,
|
||||
target,
|
||||
rel,
|
||||
variant = 'default',
|
||||
external,
|
||||
children
|
||||
}: LinkProps) {
|
||||
const isExternal = external || href.startsWith('http')
|
||||
|
||||
const variantStyles = {
|
||||
default: 'text-blue-400 hover:underline',
|
||||
nav: 'text-gray-300 hover:text-white',
|
||||
muted: 'text-gray-400 hover:text-gray-300'
|
||||
}
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
href={props.href}
|
||||
className={`text-blue-400 hover:underline ${props.className}`}
|
||||
target={props.target}
|
||||
rel={props.rel}
|
||||
href={href}
|
||||
className={cn(variantStyles[variant], className)}
|
||||
target={target || (isExternal ? externalLinkProps.target : undefined)}
|
||||
rel={rel || (isExternal ? externalLinkProps.rel : undefined)}
|
||||
>
|
||||
{props.children}
|
||||
{children}
|
||||
</NextLink>
|
||||
)
|
||||
}
|
||||
26
components/objects/PageHeader.tsx
Normal file
26
components/objects/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { ReactNode } from 'react'
|
||||
|
||||
interface PageHeaderProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
subtitle?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PageHeader({ icon, title, subtitle, className }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mt-2 text-center text-gray-200 glow">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-gray-400 text-center">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,83 +1,46 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
SiNextdotjs,
|
||||
SiLucide,
|
||||
SiVercel,
|
||||
SiSimpleicons,
|
||||
SiFontawesome,
|
||||
SiShadcnui,
|
||||
SiTailwindcss
|
||||
} from "react-icons/si"
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { footerMessages } from './footerMessages'
|
||||
|
||||
export const footerMessages = [
|
||||
[
|
||||
"Built with Next.js",
|
||||
"https://nextjs.org",
|
||||
<SiNextdotjs key="nextjs" className="text-md mr-2" />
|
||||
],
|
||||
[
|
||||
"Icons by Lucide",
|
||||
"https://lucide.dev/",
|
||||
<SiLucide key="lucide" className="text-md mr-2" />
|
||||
],
|
||||
[
|
||||
"Icons by Simple Icons",
|
||||
"https://simpleicons.org/",
|
||||
<SiSimpleicons key="simpleicons" className="text-md mr-2" />
|
||||
],
|
||||
[
|
||||
"Font by Vercel",
|
||||
"https://vercel.com/font",
|
||||
<SiVercel key="vercel" className="text-md mr-2" />
|
||||
],
|
||||
[
|
||||
"Icons by Font Awesome",
|
||||
"https://fontawesome.com/",
|
||||
<SiFontawesome key="fontawesome" className="text-md mr-2" />
|
||||
],
|
||||
[
|
||||
"Components by Shadcn",
|
||||
"https://ui.shadcn.com/",
|
||||
<SiShadcnui key="shadcn" className="text-md mr-2" />
|
||||
],
|
||||
[
|
||||
"Styled with Tailwind",
|
||||
"https://tailwindcss.com/",
|
||||
<SiTailwindcss key="tailwind" className="text-md mr-2" />
|
||||
]
|
||||
]
|
||||
interface RandomFooterMsgProps {
|
||||
index?: number
|
||||
}
|
||||
|
||||
export default function RandomFooterMsg() {
|
||||
const [randomIndex, setRandomIndex] = useState(0)
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
const fallbackMessage = footerMessages[0] ?? null
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
setRandomIndex(Math.floor(Math.random() * footerMessages.length))
|
||||
}, [])
|
||||
|
||||
if (!isMounted) {
|
||||
const [message, url, icon] = footerMessages[0]
|
||||
return (
|
||||
<Link href={String(url)} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors mb-2 sm:mb-0">
|
||||
<div className="flex items-center justify-center">
|
||||
{icon}
|
||||
{message}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
const getMessageByIndex = (index: number | undefined) => {
|
||||
if (!footerMessages.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [message, url, icon] = footerMessages[randomIndex]
|
||||
if (typeof index !== 'number' || Number.isNaN(index)) {
|
||||
return fallbackMessage
|
||||
}
|
||||
|
||||
const safeIndex = ((Math.floor(index) % footerMessages.length) + footerMessages.length) % footerMessages.length
|
||||
return footerMessages[safeIndex] ?? fallbackMessage
|
||||
}
|
||||
|
||||
export default function RandomFooterMsg({ index }: RandomFooterMsgProps) {
|
||||
const message = getMessageByIndex(index)
|
||||
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { text, url, Icon } = message
|
||||
|
||||
return (
|
||||
<Link href={String(url)} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors mb-2 sm:mb-0">
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-white transition-colors mb-2 sm:mb-0"
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
{icon}
|
||||
{message}
|
||||
<Icon className="text-md mr-2" />
|
||||
{text}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
|
|
|
|||
54
components/objects/footerMessages.ts
Normal file
54
components/objects/footerMessages.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { IconType } from 'react-icons'
|
||||
import {
|
||||
SiFontawesome,
|
||||
SiLucide,
|
||||
SiNextdotjs,
|
||||
SiShadcnui,
|
||||
SiSimpleicons,
|
||||
SiTailwindcss,
|
||||
SiVercel
|
||||
} from 'react-icons/si'
|
||||
|
||||
export type FooterMessage = {
|
||||
text: string
|
||||
url: string
|
||||
Icon: IconType
|
||||
}
|
||||
|
||||
export const footerMessages: FooterMessage[] = [
|
||||
{
|
||||
text: 'Built with Next.js',
|
||||
url: 'https://nextjs.org',
|
||||
Icon: SiNextdotjs
|
||||
},
|
||||
{
|
||||
text: 'Icons by Lucide',
|
||||
url: 'https://lucide.dev/',
|
||||
Icon: SiLucide
|
||||
},
|
||||
{
|
||||
text: 'Icons by Simple Icons',
|
||||
url: 'https://simpleicons.org/',
|
||||
Icon: SiSimpleicons
|
||||
},
|
||||
{
|
||||
text: 'Font by Vercel',
|
||||
url: 'https://vercel.com/font',
|
||||
Icon: SiVercel
|
||||
},
|
||||
{
|
||||
text: 'Icons by Font Awesome',
|
||||
url: 'https://fontawesome.com/',
|
||||
Icon: SiFontawesome
|
||||
},
|
||||
{
|
||||
text: 'Components by Shadcn',
|
||||
url: 'https://ui.shadcn.com/',
|
||||
Icon: SiShadcnui
|
||||
},
|
||||
{
|
||||
text: 'Styled with Tailwind',
|
||||
url: 'https://tailwindcss.com/',
|
||||
Icon: SiTailwindcss
|
||||
}
|
||||
]
|
||||
72
components/ui/Card.tsx
Normal file
72
components/ui/Card.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { cn, surfaces } from '@/lib/theme'
|
||||
|
||||
type CardVariant = keyof typeof surfaces.card
|
||||
type SectionVariant = keyof typeof surfaces.section
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
title?: ReactNode
|
||||
variant?: CardVariant | SectionVariant
|
||||
className?: string
|
||||
spanCols?: number
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Versatile card component with optional title and column spanning.
|
||||
*
|
||||
* Supports both card and section variants from the theme system.
|
||||
* Can display an optional title (string or ReactNode with icons) and span multiple grid columns.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Simple card
|
||||
* <Card variant="default">Content</Card>
|
||||
*
|
||||
* // Section card with title
|
||||
* <Card variant="default" title="My Section">Content</Card>
|
||||
*
|
||||
* // Card with icon in title
|
||||
* <Card title={<div className="flex items-center gap-2"><Icon />Title</div>}>
|
||||
* Content
|
||||
* </Card>
|
||||
*
|
||||
* // Card spanning 2 columns
|
||||
* <Card spanCols={2}>Wide content</Card>
|
||||
* ```
|
||||
*/
|
||||
export function Card({
|
||||
children,
|
||||
title,
|
||||
variant = 'default',
|
||||
className,
|
||||
spanCols,
|
||||
onClick
|
||||
}: CardProps) {
|
||||
let variantClass: string
|
||||
|
||||
if (variant in surfaces.card) {
|
||||
variantClass = surfaces.card[variant as CardVariant]
|
||||
} else if (variant in surfaces.section) {
|
||||
variantClass = surfaces.section[variant as SectionVariant]
|
||||
} else {
|
||||
variantClass = surfaces.card.default
|
||||
}
|
||||
|
||||
const colSpanClass = spanCols ? `lg:col-span-${spanCols}` : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(variantClass, colSpanClass, className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{title && (
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
components/ui/CardGrid.tsx
Normal file
41
components/ui/CardGrid.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CardGridProps {
|
||||
children: ReactNode
|
||||
cols?: '2' | '3' | '4'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive card grid layout component.
|
||||
*
|
||||
* Provides a consistent grid system for card layouts with mobile-first responsive breakpoints.
|
||||
* Default is 3 columns (1 on mobile, 2 on tablet, 3 on desktop).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <CardGrid cols="3">
|
||||
* <Card>Card 1</Card>
|
||||
* <Card>Card 2</Card>
|
||||
* <Card>Card 3</Card>
|
||||
* </CardGrid>
|
||||
* ```
|
||||
*/
|
||||
export function CardGrid({
|
||||
children,
|
||||
cols = '3',
|
||||
className
|
||||
}: CardGridProps) {
|
||||
const gridClasses = {
|
||||
'2': 'grid grid-cols-1 md:grid-cols-2 gap-4 p-4',
|
||||
'3': 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4',
|
||||
'4': 'grid grid-cols-2 md:grid-cols-4 gap-4'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(gridClasses[cols], className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
components/ui/PaginatedCardList.tsx
Normal file
95
components/ui/PaginatedCardList.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useState, useMemo, type ReactNode } from 'react'
|
||||
|
||||
interface PaginatedCardListProps<T> {
|
||||
items: T[]
|
||||
renderItem: (item: T, index: number) => ReactNode
|
||||
itemsPerPage: number
|
||||
title: string
|
||||
icon?: ReactNode
|
||||
subtitle?: string
|
||||
/** Function to extract unique key from item */
|
||||
getItemKey?: (item: T, index: number) => string | number
|
||||
}
|
||||
|
||||
export default function PaginatedCardList<T>({
|
||||
items,
|
||||
renderItem,
|
||||
itemsPerPage,
|
||||
title,
|
||||
icon,
|
||||
subtitle,
|
||||
getItemKey
|
||||
}: PaginatedCardListProps<T>) {
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
const { totalPages, currentItems, startIndex } = useMemo(() => {
|
||||
const totalPages = Math.ceil(items.length / itemsPerPage)
|
||||
const startIndex = (currentPage - 1) * itemsPerPage
|
||||
const endIndex = startIndex + itemsPerPage
|
||||
const currentItems = items.slice(startIndex, endIndex)
|
||||
|
||||
return { totalPages, currentItems, startIndex }
|
||||
}, [items, itemsPerPage, currentPage])
|
||||
|
||||
const goToNextPage = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPreviousPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="p-4 sm:p-6 lg:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 flex flex-col min-h-[500px] sm:min-h-[600px]">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-2 mb-4 sm:mb-6">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-gray-200 flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-muted-foreground italic text-xs sm:text-sm">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4 flex-grow mb-4 sm:mb-6 min-h-[300px] sm:min-h-[400px]">
|
||||
{currentItems.map((item, index) => {
|
||||
const globalIndex = startIndex + index
|
||||
const key = getItemKey ? getItemKey(item, globalIndex) : globalIndex
|
||||
return <div key={key}>{renderItem(item, globalIndex)}</div>
|
||||
})}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-auto pt-4 sm:pt-6 pb-1 sm:pb-2 border-t border-gray-700">
|
||||
<button
|
||||
onClick={goToPreviousPage}
|
||||
disabled={currentPage === 1}
|
||||
className="flex items-center gap-1 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm text-gray-300 hover:text-gray-100 disabled:text-gray-600 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={14} className="sm:w-4 sm:h-4" />
|
||||
<span className="hidden sm:inline">Previous</span>
|
||||
<span className="sm:hidden">Prev</span>
|
||||
</button>
|
||||
<span className="text-xs sm:text-sm text-gray-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="flex items-center gap-1 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm text-gray-300 hover:text-gray-100 disabled:text-gray-600 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={14} className="sm:w-4 sm:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
32
components/ui/Section.tsx
Normal file
32
components/ui/Section.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { cn, surfaces } from '@/lib/theme'
|
||||
|
||||
interface SectionProps {
|
||||
children: ReactNode
|
||||
variant?: keyof typeof surfaces.section
|
||||
className?: string
|
||||
id?: string
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
export function Section({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
id,
|
||||
title
|
||||
}: SectionProps) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(surfaces.section[variant], className)}
|
||||
>
|
||||
{title && (
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
20
components/ui/Surface.tsx
Normal file
20
components/ui/Surface.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { cn, surfaces } from '@/lib/theme'
|
||||
|
||||
interface SurfaceProps {
|
||||
children: ReactNode
|
||||
variant?: keyof typeof surfaces.panel
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Surface({
|
||||
children,
|
||||
variant = 'dropdown',
|
||||
className
|
||||
}: SurfaceProps) {
|
||||
return (
|
||||
<div className={cn(surfaces.panel[variant], className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +1,22 @@
|
|||
import { SiGithub, SiForgejo } from "react-icons/si"
|
||||
import { TbStar, TbGitBranch } from "react-icons/tb"
|
||||
import featuredProjects from "@/public/data/featured.json"
|
||||
import Link from "next/link"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { FeaturedProject } from "@/lib/github"
|
||||
|
||||
export default function GitHubFeatured({ className }: { className?: string }) {
|
||||
interface FeaturedReposProps {
|
||||
projects: FeaturedProject[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function FeaturedRepos({ projects, className }: FeaturedReposProps) {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-1 md:grid-cols-2 gap-4", className)}>
|
||||
{featuredProjects.map((project) => (
|
||||
{projects.map((project) => (
|
||||
<div key={project.id} className="bg-gray-800 p-6 rounded-lg shadow-md min-h-[200px] flex flex-col">
|
||||
<div className="flex-1">
|
||||
<h3 className="flex items-center justify-center text-xl font-bold text-gray-100 mb-3">
|
||||
{project.github ? <SiGithub className="mr-2" /> : <SiForgejo className="mr-2" />} {project.name}
|
||||
{project.platform === 'github' ? <SiGithub className="mr-2" /> : <SiForgejo className="mr-2" />} {project.name}
|
||||
</h3>
|
||||
<p className="text-gray-300 grow">{project.description}</p>
|
||||
</div>
|
||||
|
|
|
|||
41
components/widgets/GitHubStatsImage.tsx
Normal file
41
components/widgets/GitHubStatsImage.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client"
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface GitHubStatsImageProps {
|
||||
username: string
|
||||
}
|
||||
|
||||
export default function GitHubStatsImage({ username }: GitHubStatsImageProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
if (imageError) { return null }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center w-full mt-4 gap-4">
|
||||
<Image
|
||||
src={`https://github-readme-stats.vercel.app/api?username=${username}&theme=dark&show_icons=true&hide_border=true&count_private=true`}
|
||||
alt={`${username}'s Stats`}
|
||||
width={420}
|
||||
height={200}
|
||||
onError={() => setImageError(true)}
|
||||
loading="eager"
|
||||
priority
|
||||
unoptimized
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
<Image
|
||||
src={`https://github-readme-stats.vercel.app/api/top-langs/?username=${username}&theme=dark&show_icons=true&hide_border=true&layout=compact`}
|
||||
alt={`${username}'s Top Languages`}
|
||||
width={300}
|
||||
height={200}
|
||||
onError={() => setImageError(true)}
|
||||
loading="eager"
|
||||
priority
|
||||
unoptimized
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { Progress } from "@/components/ui/progress"
|
|||
import Link from "@/components/objects/Link"
|
||||
import ScrollTxt from "@/components/objects/MusicText"
|
||||
import { connectSocket, disconnectSocket } from "@/lib/socket"
|
||||
import { effects } from '@/lib/theme/effects'
|
||||
|
||||
interface LastFmResponse {
|
||||
album?: {
|
||||
|
|
@ -148,7 +149,7 @@ const NowPlaying: React.FC = () => {
|
|||
href={nowPlaying.mbid ? `https://musicbrainz.org/release/${nowPlaying.mbid}` : `https://listenbrainz.org/user/p0ntus`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-gradient-to-b from-gray-700 to-gray-900 border-b border-gray-700 px-2 py-0 block" style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}
|
||||
className="border-b border-gray-700 px-2 py-0 block" style={{background: effects.gradients.musicPlayer}}
|
||||
>
|
||||
<div className="text-center leading-none pb-1">
|
||||
<ScrollTxt text={nowPlaying.artist_name?.toUpperCase() || ''} type="artist" className="-mt-0.5" />
|
||||
|
|
@ -196,7 +197,7 @@ const NowPlaying: React.FC = () => {
|
|||
{/* Virtual screen */}
|
||||
<div className="mx-2 mt-2 flex-1 bg-black overflow-hidden flex flex-col">
|
||||
{screenOn && (
|
||||
<div className="bg-gradient-to-b from-gray-700 via-gray-800 to-gray-900 border-b border-gray-700" style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}>
|
||||
<div className="border-b border-gray-700" style={{background: effects.gradients.musicPlayer}}>
|
||||
<div className="relative flex items-center pr-1 py-0.5">
|
||||
<FaBluetoothB size={14} className="text-gray-400" />
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 text-white text-xs font-medium">{formatTime(currentTime)}</div>
|
||||
|
|
@ -212,7 +213,7 @@ const NowPlaying: React.FC = () => {
|
|||
)}
|
||||
{/* Player controls and seekbar */}
|
||||
{screenOn && nowPlaying.track_name && (
|
||||
<div className={`bg-gradient-to-b from-gray-700 to-gray-900 ${nowPlaying.release_name ? "pb-3" : "pb-[12.5px]"} flex flex-col items-center`} style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}>
|
||||
<div className={`${nowPlaying.release_name ? "pb-3" : "pb-[12.5px]"} flex flex-col items-center`} style={{background: effects.gradients.musicPlayer}}>
|
||||
<div className="flex justify-center items-center gap-0 px-2">
|
||||
<button className="hover:drop-shadow-[0_0_8px_rgba(255,255,255,0.9)] hover:filter hover:brightness-110 transition-all duration-200 p-1 rounded-full overflow-hidden">
|
||||
<svg width="38" height="34" viewBox="0 0 24 20" className="drop-shadow-sm">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue