mirror of
https://github.com/netbirdio/docs.git
synced 2026-09-02 13:11:25 +02:00
Style Consistency Changes and Element Improvements (#541)
Co-authored-by: braginini <bangvalo@gmail.com>
This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
import { Button } from '@/components/Button'
|
||||
import { Heading } from '@/components/Heading'
|
||||
|
||||
const aboutNetbird = [
|
||||
{
|
||||
href: '/about-netbird/how-netbird-works',
|
||||
name: 'How NetBird works',
|
||||
description: 'Concepts, architecture, protocols, and more.',
|
||||
},
|
||||
{
|
||||
href: '/about-netbird/netbird-vs-traditional-vpn',
|
||||
name: 'NetBird vs. traditional VPN',
|
||||
description:
|
||||
'Learn how NetBird compares to traditional VPNs and why it is better.',
|
||||
},
|
||||
{
|
||||
href: '/about-netbird/why-wireguard-with-netbird',
|
||||
name: 'Why WireGuard with NetBird',
|
||||
description:
|
||||
'Learn why and how NetBird uses WireGuard.',
|
||||
},
|
||||
]
|
||||
|
||||
export function AboutNetbird() {
|
||||
return (
|
||||
<div className="my-16 xl:max-w-none">
|
||||
<Heading level={2} id="howNetbirdWorks">
|
||||
About NetBird
|
||||
</Heading>
|
||||
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{aboutNetbird.map((guide) => (
|
||||
<div key={guide.href}>
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
{guide.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{guide.description}
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
<Button href={guide.href} variant="text" arrow="right">
|
||||
Read more
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,14 +16,14 @@ function ArrowIcon(props) {
|
||||
|
||||
const variantStyles = {
|
||||
primary:
|
||||
'rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-orange-400/10 dark:text-orange-400 dark:ring-1 dark:ring-inset dark:ring-orange-400/20 dark:hover:bg-orange-400/10 dark:hover:text-orange-300 dark:hover:ring-orange-300',
|
||||
'rounded-[5px] bg-netbird text-white border-0 border-transparent duration-300 relative overflow-hidden group',
|
||||
secondary:
|
||||
'rounded-full bg-zinc-100 py-1 px-3 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800/40 dark:text-zinc-400 dark:ring-1 dark:ring-inset dark:ring-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300',
|
||||
filled:
|
||||
'rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400',
|
||||
'rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-netbird dark:text-white dark:hover:bg-netbird-dark',
|
||||
outline:
|
||||
'rounded-full py-1 px-3 text-zinc-700 ring-1 ring-inset ring-zinc-900/10 hover:bg-zinc-900/2.5 hover:text-zinc-900 dark:text-zinc-400 dark:ring-white/10 dark:hover:bg-white/5 dark:hover:text-white',
|
||||
text: 'text-orange-500 hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-500',
|
||||
text: 'text-netbird hover:text-netbird-dark dark:text-netbird dark:hover:text-netbird-light',
|
||||
}
|
||||
|
||||
export function Button({
|
||||
@@ -36,7 +36,7 @@ export function Button({
|
||||
let Component = props.href ? Link : 'button'
|
||||
|
||||
className = clsx(
|
||||
'inline-flex gap-0.5 justify-center overflow-hidden text-sm font-medium transition whitespace-nowrap',
|
||||
'inline-flex gap-0.5 justify-center text-[12px] md:text-xs px-3 py-2 md:px-3 md:py-2 lg:px-4 lg:py-2.5 font-medium transition whitespace-nowrap items-center',
|
||||
variantStyles[variant],
|
||||
className
|
||||
)
|
||||
@@ -52,6 +52,22 @@ export function Button({
|
||||
/>
|
||||
)
|
||||
|
||||
if (variant === 'primary') {
|
||||
return (
|
||||
<div className="relative inline-flex group transition-all" onClick={props.href ? undefined : props.onClick}>
|
||||
<span className="absolute h-full w-full left-0 top-0 blur-sm bg-netbird z-0 transition-all duration-200 transform-gpu opacity-0 group-hover:opacity-100 pointer-events-none"></span>
|
||||
<Component className={className} {...props}>
|
||||
<span className="absolute h-full w-full left-0 top-0 z-10 bg-gradient-to-br from-netbird to-netbird-dark transition-all duration-200 transform-gpu opacity-0 group-hover:opacity-100 pointer-events-none"></span>
|
||||
<span className="z-20 relative flex gap-2 items-center transition-all">
|
||||
{arrow === 'left' && arrowIcon}
|
||||
{children}
|
||||
{arrow === 'right' && arrowIcon}
|
||||
</span>
|
||||
</Component>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Component className={className} {...props}>
|
||||
{arrow === 'left' && arrowIcon}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { forwardRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
import { Button } from '@/components/Button'
|
||||
import { Logo } from '@/components/Logo'
|
||||
@@ -16,10 +16,10 @@ import { useAnnouncements } from '@/components/announcement-banner/AnnouncementB
|
||||
|
||||
function TopLevelNavItem({ href, children }) {
|
||||
return (
|
||||
<li>
|
||||
<li className="block text-[12px] lg:text-[13.5px] m-0 p-0 leading-none">
|
||||
<Link
|
||||
href={href}
|
||||
className="text-sm leading-5 text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
|
||||
className="px-2 lg:px-3 py-1 lg:py-2 opacity-60 hover:opacity-100 hover:bg-zinc-900/5 dark:hover:bg-neutral-900/60 hover:border-zinc-900/10 dark:hover:border-neutral-800 border border-transparent rounded-md leading-none transition-all duration-200 text-zinc-900 dark:text-white inline-flex items-center"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
@@ -32,45 +32,37 @@ export const Header = forwardRef(function Header({ className }, ref) {
|
||||
let isInsideMobileNavigation = useIsInsideMobileNavigation()
|
||||
let { bannerHeight } = useAnnouncements()
|
||||
|
||||
let { scrollY } = useScroll()
|
||||
let bgOpacityLight = useTransform(scrollY, [0, 72], [0.5, 0.9])
|
||||
let bgOpacityDark = useTransform(scrollY, [0, 72], [0.2, 0.8])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
className,
|
||||
'fixed inset-x-0 top-0 z-40 flex h-14 items-center justify-between gap-12 px-4 transition sm:px-6 lg:left-72 lg:z-30 lg:px-8 xl:left-80',
|
||||
!isInsideMobileNavigation &&
|
||||
'backdrop-blur-sm dark:backdrop-blur lg:left-72 xl:left-80',
|
||||
isInsideMobileNavigation
|
||||
? 'bg-white dark:bg-zinc-900'
|
||||
: 'bg-white/[var(--bg-opacity-light)] dark:bg-zinc-900/[var(--bg-opacity-dark)]'
|
||||
)}
|
||||
className={clsx(
|
||||
className,
|
||||
'fixed inset-x-0 top-0 z-40 flex items-center justify-between gap-3 px-5 transition h-[64px] lg:left-72 lg:z-50 lg:px-8 xl:left-80 min-h-[64px] lg:pointer-events-auto',
|
||||
!isInsideMobileNavigation &&
|
||||
'backdrop-blur-lg bg-white/70 dark:bg-[#181A1D]/95 lg:left-72 xl:left-80',
|
||||
isInsideMobileNavigation &&
|
||||
'bg-white/70 dark:bg-[#181A1D]/95 backdrop-blur-lg'
|
||||
)}
|
||||
style={{
|
||||
'--bg-opacity-light': bgOpacityLight,
|
||||
'--bg-opacity-dark': bgOpacityDark,
|
||||
top: bannerHeight,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute inset-x-0 top-full h-px transition',
|
||||
(isInsideMobileNavigation || !mobileNavIsOpen) &&
|
||||
'bg-zinc-900/7.5 dark:bg-white/7.5'
|
||||
'absolute inset-x-0 top-full h-px transition border-b border-transparent',
|
||||
!isInsideMobileNavigation && 'border-zinc-900/10 dark:border-neutral-700/50'
|
||||
)}
|
||||
/>
|
||||
<Search />
|
||||
<div className="flex items-center gap-5 lg:hidden">
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
<MobileNavigation />
|
||||
<Link href="/" aria-label="Home">
|
||||
<Logo className="h-6" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex items-center gap-3 xl:gap-2">
|
||||
<nav className="hidden md:block">
|
||||
<ul role="list" className="flex items-center gap-8">
|
||||
<ul role="list" className="flex items-center gap-3 xl:gap-2 m-0 p-0 list-none">
|
||||
<TopLevelNavItem href="https://netbird.io/">Home</TopLevelNavItem>
|
||||
<TopLevelNavItem href="/">Docs</TopLevelNavItem>
|
||||
<TopLevelNavItem href="/api">API</TopLevelNavItem>
|
||||
@@ -79,8 +71,8 @@ export const Header = forwardRef(function Header({ className }, ref) {
|
||||
<TopLevelNavItem href="/slack-url">Support</TopLevelNavItem>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="hidden md:block md:h-5 md:w-px md:bg-zinc-900/10 md:dark:bg-white/15" />
|
||||
<div className="flex gap-4">
|
||||
<div className="hidden md:block md:h-5 md:w-px md:bg-zinc-900/10 md:dark:bg-neutral-500/20" />
|
||||
<div className="flex gap-2">
|
||||
<MobileSearch />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
||||
@@ -3,30 +3,31 @@ import { GridPattern } from '@/components/GridPattern'
|
||||
export function HeroPattern() {
|
||||
return (
|
||||
<div className="absolute inset-0 -z-10 mx-0 max-w-none overflow-hidden">
|
||||
<div className="absolute left-1/2 top-0 ml-[-38rem] h-[25rem] w-[81.25rem] dark:[mask-image:linear-gradient(white,transparent)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#FFAC1C] to-[#F28C28] opacity-40 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)] dark:from-[#F28C28]/30 dark:to-[#FF7518]/30 dark:opacity-100">
|
||||
<GridPattern
|
||||
width={72}
|
||||
height={56}
|
||||
x="-12"
|
||||
y="4"
|
||||
squares={[
|
||||
[4, 3],
|
||||
[2, 1],
|
||||
[7, 3],
|
||||
[10, 6],
|
||||
]}
|
||||
className="absolute inset-x-0 inset-y-[-50%] h-[200%] w-full skew-y-[-18deg] fill-black/40 stroke-black/50 mix-blend-overlay dark:fill-white/2.5 dark:stroke-white/5"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 hidden dark:block z-0" style={{ backgroundColor: '#181A1D' }}>
|
||||
<GridPattern
|
||||
width={72}
|
||||
height={56}
|
||||
x="-12"
|
||||
y="4"
|
||||
squares={[
|
||||
[4, 3],
|
||||
[2, 1],
|
||||
[7, 3],
|
||||
[10, 6],
|
||||
]}
|
||||
className="absolute inset-x-0 inset-y-[-50%] h-[200%] w-full skew-y-[-18deg] fill-black/40 stroke-black/50 mix-blend-overlay dark:fill-white/2.5 dark:stroke-white/5"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-x-0 top-0 h-[25rem] dark:[mask-image:linear-gradient(white,transparent)] pointer-events-none z-10">
|
||||
<svg
|
||||
viewBox="0 0 1113 440"
|
||||
preserveAspectRatio="xMidYMin slice"
|
||||
aria-hidden="true"
|
||||
className="absolute left-1/2 top-0 ml-[-19rem] w-[69.5625rem] fill-white blur-[26px] dark:hidden"
|
||||
className="absolute left-1/2 top-0 -translate-x-1/2 w-full min-w-[69.5625rem] h-full fill-white blur-[26px] dark:hidden"
|
||||
>
|
||||
<path d="M.016 439.5s-9.5-300 434-300S882.516 20 882.516 20V0h230.004v439.5H.016Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Button } from '@/components/Button'
|
||||
import { Heading } from '@/components/Heading'
|
||||
|
||||
const howToGuides = [
|
||||
{
|
||||
href: '/get-started',
|
||||
name: 'Quickstart guide',
|
||||
description: 'Start using NetBird in under 5 minutes.',
|
||||
},
|
||||
{
|
||||
href: '/manage/access-control/manage-network-access',
|
||||
name: 'Manage network access',
|
||||
description:
|
||||
'Learn how to use access controls to manage access to your machines.',
|
||||
},
|
||||
{
|
||||
href: '/manage/team/add-users-to-your-network',
|
||||
name: 'Add users to your network',
|
||||
description: 'Learn how to add team members to your NetBird network.',
|
||||
},
|
||||
{
|
||||
href: '/manage/network-routes/routing-traffic-to-private-networks',
|
||||
name: 'Route traffic to private networks',
|
||||
description:
|
||||
'Learn how to provide access to LANs, VPS, and corporate private networks.',
|
||||
},
|
||||
{
|
||||
href: '/manage/network-routes/configuring-default-routes-for-internet-traffic',
|
||||
name: 'Configure default routes and traffic for the Internet',
|
||||
description: 'Understand how to set up your network for accessing the internet through default routes, also known as "exit nodes".',
|
||||
},
|
||||
{
|
||||
href: '/manage/activity/traffic-events-logging',
|
||||
name: 'Log and monitor network activity',
|
||||
description:
|
||||
'Learn how to keep track of system and network activities in your account.',
|
||||
},
|
||||
{
|
||||
href: '/manage/dns',
|
||||
name: 'Manage DNS in your network',
|
||||
description:
|
||||
'Learn how to configure name servers in your private network.',
|
||||
},
|
||||
]
|
||||
|
||||
export function HowToGuides() {
|
||||
return (
|
||||
<div className="my-16 xl:max-w-none">
|
||||
<Heading level={2} id="guides">
|
||||
How-To Guides
|
||||
</Heading>
|
||||
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{howToGuides.map((guide) => (
|
||||
<div key={guide.href}>
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
{guide.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{guide.description}
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
<Button href={guide.href} variant="text" arrow="right">
|
||||
Read more
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -183,15 +183,15 @@ export function Layout({ children, title, tableOfContents }) {
|
||||
className="contents lg:pointer-events-none lg:fixed lg:inset-0 lg:z-40 lg:flex"
|
||||
style={{ top: bannerHeight }}
|
||||
>
|
||||
<div className="contents lg:pointer-events-auto lg:block lg:w-72 lg:overflow-y-auto lg:border-r lg:border-zinc-900/10 lg:px-6 lg:pb-8 lg:pt-4 lg:dark:border-white/10 xl:w-80">
|
||||
<div className="contents lg:pointer-events-auto lg:block lg:w-72 lg:overflow-y-auto lg:border-r lg:border-zinc-900/10 lg:dark:border-neutral-700/50 lg:px-6 lg:pb-8 lg:pt-4 lg:bg-white/70 lg:dark:bg-[#181A1D]/95 lg:backdrop-blur-lg xl:w-80 lg:overflow-x-visible">
|
||||
<div className="hidden lg:flex">
|
||||
<Link href="/" aria-label="Home">
|
||||
<Logo className="h-6" />
|
||||
</Link>
|
||||
</div>
|
||||
<Header />
|
||||
{router.route.startsWith("/ipa") ? <NavigationAPI className="hidden lg:mt-10 lg:block" tableOfContents={tableOfContents} /> : <NavigationDocs className="hidden lg:mt-10 lg:block" />}
|
||||
</div>
|
||||
<Header />
|
||||
</header>
|
||||
<div className="min-w-0 max-w-2xl flex-auto px-4 py-16 lg:max-w-none lg:pl-8 lg:pr-0 xl:px-5">
|
||||
<main className="py-16">
|
||||
|
||||
@@ -78,7 +78,7 @@ export function MobileNavigation() {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 top-14 bg-zinc-400/20 backdrop-blur-sm dark:bg-black/40" />
|
||||
<div className="fixed inset-0 top-14 bg-zinc-400/20 backdrop-blur-sm dark:bg-[#181A1D]/40" />
|
||||
</Transition.Child>
|
||||
|
||||
<Dialog.Panel>
|
||||
@@ -105,7 +105,7 @@ export function MobileNavigation() {
|
||||
>
|
||||
<motion.div
|
||||
layoutScroll
|
||||
className="fixed bottom-0 left-0 top-14 w-full overflow-y-auto bg-white px-4 pb-4 pt-6 shadow-lg shadow-zinc-900/10 ring-1 ring-zinc-900/7.5 dark:bg-zinc-900 dark:ring-zinc-800 min-[416px]:max-w-sm sm:px-6 sm:pb-10"
|
||||
className="fixed bottom-0 left-0 top-14 w-full overflow-y-auto bg-white/70 dark:bg-[#181A1D]/95 backdrop-blur-lg px-4 pb-4 pt-6 shadow-lg shadow-zinc-900/10 ring-1 ring-zinc-900/7.5 dark:ring-neutral-500/10 min-[416px]:max-w-sm sm:px-6 sm:pb-10"
|
||||
>
|
||||
{router.route.startsWith("/ipa") ? <NavigationAPI tableOfContents={[]} /> :
|
||||
<NavigationDocs />}
|
||||
|
||||
90
src/components/Tiles.jsx
Normal file
90
src/components/Tiles.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import Link from 'next/link'
|
||||
import { motion, useMotionTemplate, useMotionValue } from 'framer-motion'
|
||||
|
||||
import { GridPattern } from '@/components/GridPattern'
|
||||
import { Heading } from '@/components/Heading'
|
||||
|
||||
function TilePattern({ mouseX, mouseY }) {
|
||||
let maskImage = useMotionTemplate`radial-gradient(180px at ${mouseX}px ${mouseY}px, white, transparent)`
|
||||
let style = { maskImage, WebkitMaskImage: maskImage }
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none">
|
||||
<div className="absolute inset-0 rounded-2xl transition duration-300 [mask-image:linear-gradient(white,transparent)] group-hover:opacity-50">
|
||||
<GridPattern
|
||||
width={72}
|
||||
height={56}
|
||||
x="50%"
|
||||
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/[0.02] stroke-black/5 dark:fill-white/1 dark:stroke-white/2.5"
|
||||
/>
|
||||
</div>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-[#FFAC1C] to-[#F28C28] opacity-0 transition duration-300 group-hover:opacity-30 dark:group-hover:opacity-60 dark:from-[#F28C28]/30 dark:to-[#FF7518]/30"
|
||||
style={style}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-2xl opacity-0 mix-blend-overlay transition duration-300 group-hover:opacity-100"
|
||||
style={style}
|
||||
>
|
||||
<GridPattern
|
||||
width={72}
|
||||
height={56}
|
||||
x="50%"
|
||||
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/50 stroke-black/70 dark:fill-white/2.5 dark:stroke-white/10"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Tiles component for displaying a grid of tile items
|
||||
*
|
||||
* @param {string} title - The heading title for the tiles section
|
||||
* @param {string} [id] - Optional id for the heading anchor
|
||||
* @param {Array<{href: string, name: string, description: string}>} items - Array of tile items
|
||||
* @param {string} [buttonText='Read more'] - Optional button text (defaults to "Read more")
|
||||
*/
|
||||
export function Tiles({ title, id, items, buttonText = 'Read more' }) {
|
||||
return (
|
||||
<div className="my-16 xl:max-w-none">
|
||||
<Heading level={2} id={id} anchor={!!id}>
|
||||
{title}
|
||||
</Heading>
|
||||
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2">
|
||||
{items.map((item) => {
|
||||
let mouseX = useMotionValue(0)
|
||||
let mouseY = useMotionValue(0)
|
||||
|
||||
function onMouseMove({ currentTarget, clientX, clientY }) {
|
||||
let { left, top } = currentTarget.getBoundingClientRect()
|
||||
mouseX.set(clientX - left)
|
||||
mouseY.set(clientY - top)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.href}
|
||||
onMouseMove={onMouseMove}
|
||||
className="group relative flex rounded-2xl bg-zinc-50 transition-shadow hover:shadow-md hover:shadow-zinc-900/5 dark:bg-white/2.5 dark:hover:shadow-black/5"
|
||||
>
|
||||
<TilePattern mouseX={mouseX} mouseY={mouseY} />
|
||||
<div className="absolute inset-0 rounded-2xl ring-1 ring-inset ring-zinc-900/7.5 group-hover:ring-zinc-900/10 dark:ring-white/10 dark:group-hover:ring-white/20" />
|
||||
<div className="relative rounded-2xl px-4 pb-4 pt-4">
|
||||
<h3 className="text-sm font-semibold leading-7 text-zinc-900 dark:text-white">
|
||||
<Link href={item.href}>
|
||||
<span className="absolute inset-0 rounded-2xl" />
|
||||
{item.name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
src/components/YouTube.jsx
Normal file
123
src/components/YouTube.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
/**
|
||||
* Extracts YouTube video ID from various YouTube URL formats
|
||||
*/
|
||||
function extractYouTubeId(url) {
|
||||
if (!url) return null
|
||||
|
||||
// If it's already just an ID, return it
|
||||
if (!url.includes('youtube.com') && !url.includes('youtu.be') && !url.includes('/')) {
|
||||
return url
|
||||
}
|
||||
|
||||
// Handle youtu.be short links
|
||||
if (url.includes('youtu.be/')) {
|
||||
const match = url.match(/youtu\.be\/([a-zA-Z0-9_-]+)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
// Handle youtube.com/watch?v=...
|
||||
if (url.includes('youtube.com/watch')) {
|
||||
const match = url.match(/[?&]v=([a-zA-Z0-9_-]+)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
// Handle youtube.com/embed/...
|
||||
if (url.includes('youtube.com/embed/')) {
|
||||
const match = url.match(/embed\/([a-zA-Z0-9_-]+)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube video embed component
|
||||
*
|
||||
* Usage:
|
||||
* <YouTube videoId="CFa7SY4Up9k" />
|
||||
* <YouTube url="https://www.youtube.com/watch?v=CFa7SY4Up9k" />
|
||||
* <YouTube videoId="CFa7SY4Up9k" title="Video Title" />
|
||||
* <YouTube videoId="CFa7SY4Up9k" start={175} />
|
||||
* <YouTube videoId="CFa7SY4Up9k" color="white" modestbranding={1} />
|
||||
*/
|
||||
export function YouTube({
|
||||
videoId,
|
||||
url,
|
||||
title,
|
||||
start,
|
||||
color = 'white', // 'red' | 'white' - controls progress bar color (default: white)
|
||||
modestbranding = 1, // 0 | 1 - reduces YouTube branding (1 = minimal, default: 1 = minimal)
|
||||
controls, // 0 | 1 | 2 - show/hide player controls (0 = hide, 1 = show, 2 = delayed)
|
||||
rel = 1, // 0 | 1 - show related videos from same channel (0 = hide, 1 = show, default: 1 = show)
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
// Extract video ID from URL if provided
|
||||
const id = videoId || extractYouTubeId(url)
|
||||
|
||||
if (!id) {
|
||||
console.warn('YouTube component: No valid video ID or URL provided')
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract start time from URL if present
|
||||
let startTime = start
|
||||
if (!startTime && url) {
|
||||
const startMatch = url.match(/[?&]start=(\d+)/)
|
||||
if (startMatch) {
|
||||
startTime = parseInt(startMatch[1], 10)
|
||||
}
|
||||
}
|
||||
|
||||
// Build embed URL with parameters
|
||||
let embedUrl = `https://www.youtube.com/embed/${id}`
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (startTime) {
|
||||
params.append('start', startTime.toString())
|
||||
}
|
||||
|
||||
// Add color parameter - YouTube defaults to 'red', so we need to set 'white' explicitly
|
||||
if (color === 'white') {
|
||||
params.append('color', 'white')
|
||||
}
|
||||
if (modestbranding === 1) {
|
||||
params.append('modestbranding', '1')
|
||||
}
|
||||
if (controls !== undefined && controls !== 1) {
|
||||
params.append('controls', controls.toString())
|
||||
}
|
||||
// Only add rel parameter if explicitly set to 0 to hide related videos
|
||||
// YouTube's default is 1 (show), so we don't need to add it when rel === 1
|
||||
if (rel === 0) {
|
||||
params.append('rel', '0')
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
if (queryString) {
|
||||
embedUrl += `?${queryString}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative w-full overflow-hidden my-6',
|
||||
'ml-[10px] mr-auto',
|
||||
'max-w-[40rem] lg:max-w-[50rem]',
|
||||
className
|
||||
)}
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
{...props}
|
||||
>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={title || 'YouTube video player'}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
className="absolute top-0 left-0 w-full h-full rounded-lg border-0"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { Heading } from '@/components/Heading'
|
||||
import { YouTube } from '@/components/YouTube'
|
||||
|
||||
export const a = Link
|
||||
export { Button } from '@/components/Button'
|
||||
export { CodeGroup, Code as code, Pre as pre } from '@/components/Code'
|
||||
export { Badge } from '@/components/Badge'
|
||||
export { YouTube }
|
||||
|
||||
export const h2 = function H2(props) {
|
||||
return <Heading level={2} {...props} />
|
||||
@@ -40,6 +42,43 @@ function InfoIcon(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function WarningIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M8 3.5L3.5 12.5h9L8 3.5z"
|
||||
/>
|
||||
<circle cx="8" cy="9" r=".5" fill="none" />
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M8 6.5v1"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SuccessIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>
|
||||
<circle cx="8" cy="8" r="8" strokeWidth="0" />
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M5.5 8l2 2 3-3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Note({ children }) {
|
||||
return (
|
||||
<div className="my-6 flex gap-2.5 rounded-l border border-orange-500/20 bg-orange-50/50 p-4 leading-6 text-orange-900 dark:border-orange-500/30 dark:bg-orange-500/5 dark:text-orange-200 dark:[--tw-prose-links-hover:theme(colors.orange.300)] dark:[--tw-prose-links:theme(colors.white)]">
|
||||
@@ -51,6 +90,28 @@ export function Note({ children }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Warning({ children }) {
|
||||
return (
|
||||
<div className="my-6 flex gap-2.5 rounded-l border border-red-500/20 bg-red-50/50 p-4 leading-6 text-red-900 dark:border-red-500/30 dark:bg-red-500/5 dark:text-red-200 dark:[--tw-prose-links-hover:theme(colors.red.300)] dark:[--tw-prose-links:theme(colors.white)]">
|
||||
<WarningIcon className="mt-1 h-4 w-4 flex-none fill-red-500 stroke-white dark:fill-red-200/20 dark:stroke-red-200" />
|
||||
<div className="[&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Success({ children }) {
|
||||
return (
|
||||
<div className="my-6 flex gap-2.5 rounded-l border border-green-500/20 bg-green-50/50 p-4 leading-6 text-green-900 dark:border-green-500/30 dark:bg-green-500/5 dark:text-green-200 dark:[--tw-prose-links-hover:theme(colors.green.300)] dark:[--tw-prose-links:theme(colors.white)]">
|
||||
<SuccessIcon className="mt-1 h-4 w-4 flex-none fill-green-500 stroke-white dark:fill-green-200/20 dark:stroke-green-200" />
|
||||
<div className="[&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Row({ children }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 items-start gap-x-16 gap-y-10 xl:max-w-none xl:grid-cols-2">
|
||||
|
||||
Reference in New Issue
Block a user