diff --git a/.nvmrc b/.nvmrc index dc0bb0f..698f898 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v22.12.0 +v23.0.0 diff --git a/apps/www/app/blocks/[[...slug]]/page.tsx b/apps/www/app/blocks/[[...slug]]/page.tsx index 683d57a..884c4c0 100644 --- a/apps/www/app/blocks/[[...slug]]/page.tsx +++ b/apps/www/app/blocks/[[...slug]]/page.tsx @@ -4,15 +4,16 @@ import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock" import { DocsLayout } from "fumadocs-ui/layouts/docs" import { notFound } from "next/navigation" -import { BlockInfoPane } from "@/components/blocks/block-info-pane" -import { BlockPreviewPane } from "@/components/blocks/block-preview-pane" +import { getBlockShowcase } from "@/components/blocks/block-showcase" +import { BlockTopBar } from "@/components/blocks/breadcrumbs" +import { BlockInfoPane } from "@/components/blocks/info-pane" +import { BlockPreviewPane } from "@/components/blocks/preview-background" import { getMDXComponents } from "@/components/mdx-components" import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable" -import { getBlockShowcase } from "@/lib/block-showcase" import { blocksSource } from "@/lib/blocks-source" export const revalidate = false @@ -57,7 +58,7 @@ export default async function BlockPage(props: BlockPageProps) { tree={blocksSource.getPageTree()} >
-
+
@@ -81,20 +81,45 @@ export default async function BlockPage(props: BlockPageProps) { lg:block `} > - - - + + +
+ + + - - - - + + +
diff --git a/apps/www/app/blocks/layout.tsx b/apps/www/app/blocks/layout.tsx index 211a2aa..8e6dd3a 100644 --- a/apps/www/app/blocks/layout.tsx +++ b/apps/www/app/blocks/layout.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react" import { RootProvider } from "fumadocs-ui/provider/next" -import { BlocksSidebar } from "@/components/blocks/blocks-sidebar" +import { BlocksSidebar } from "@/components/blocks/sidebar" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { blocksSource } from "@/lib/blocks-source" @@ -19,7 +19,7 @@ export default function BlocksLayout({ children }: { children: ReactNode }) { return ( -
-
-
- -
-
- - {title} - {description} - {content} - -
- ) -} diff --git a/apps/www/components/blocks/block-preview-pane.tsx b/apps/www/components/blocks/block-preview-pane.tsx deleted file mode 100644 index 66a663c..0000000 --- a/apps/www/components/blocks/block-preview-pane.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { ReactNode } from "react" - -import { LimeplayLogo } from "@/registry/default/ui/limeplay-logo" - -export function BlockPreviewPane({ children }: { children: ReactNode }) { - return ( -
-
-
- - {children} -
-
-
- ) -} diff --git a/apps/www/lib/block-showcase.tsx b/apps/www/components/blocks/block-showcase.tsx similarity index 50% rename from apps/www/lib/block-showcase.tsx rename to apps/www/components/blocks/block-showcase.tsx index cc530c6..f36e2dc 100644 --- a/apps/www/lib/block-showcase.tsx +++ b/apps/www/components/blocks/block-showcase.tsx @@ -3,6 +3,9 @@ import type { ReactNode } from "react" import { LinearMediaPlayer } from "@/registry/default/blocks/linear-player/components/media-player" import { YouTubeMusicPlayer } from "@/registry/pro/blocks/youtube-music/components/media-player" +import { BlockPreviewPane } from "./preview-background" +import { BlockPreviewWithToolbar } from "./preview-pane" + type BlockShowcaseDefinition = { component: () => ReactNode } @@ -10,13 +13,25 @@ type BlockShowcaseDefinition = { const blockShowcaseRegistry = { "linear-player": { component: () => ( -
- -
+ +
+ + + +
+
), }, "youtube-music": { - component: () => , + component: () => ( + +
+ + + +
+
+ ), }, } satisfies Record diff --git a/apps/www/components/blocks/block-toolbar.tsx b/apps/www/components/blocks/block-toolbar.tsx new file mode 100644 index 0000000..68b0bd6 --- /dev/null +++ b/apps/www/components/blocks/block-toolbar.tsx @@ -0,0 +1,356 @@ +"use client" + +import { + CodeXmlIcon, + Maximize2Icon, + Minimize2Icon, + MoonIcon, + RotateCcwIcon, + SunIcon, +} from "lucide-react" +import { + AnimatePresence, + LayoutGroup, + motion, + useAnimationControls, +} from "motion/react" +import React, { useCallback, useEffect, useRef, useState } from "react" + +import { + StreamPanel, + StreamPanelProvider, + useStreamPanel, +} from "@/components/stream-panel" +import { useStreamPanelSync } from "@/components/stream-panel/use-stream-panel-sync" +import { PopoverTrigger } from "@/components/ui/popover" +import { cn } from "@/lib/utils" + +const PILL_TRANSITION = { bounce: 0.2, duration: 0.5, type: "spring" } as const +const CONTENT_TRANSITION = { + bounce: 0, + duration: 0.6, + type: "spring", +} as const + +type ActivePanel = "settings" | null + +interface BlockToolbarProps { + codeUrl?: string + expanded: boolean + onExpandToggle: () => void + onReload: () => void + onThemeToggle: () => void + theme: "dark" | "light" +} + +export function BlockStreamSync({ + playerType = "video", +}: { + playerType?: "audio" | "video" +}) { + const { handleLoadStream } = useStreamPanelSync() + + return ( + + ) +} + +export function BlockToolbar({ + codeUrl, + expanded, + onExpandToggle, + onReload, + onThemeToggle, + theme, +}: BlockToolbarProps) { + const { handle } = useStreamPanel() + const [activePanel, setActivePanel] = useState(null) + const rotateControls = useAnimationControls() + const rotationRef = useRef(0) + const contentRef = useRef(null) + const containerRef = useRef(null) + const [contentHeight, setContentHeight] = useState(0) + + useEffect(() => { + if (!contentRef.current) return + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + setContentHeight(entry.contentRect.height) + } + }) + ro.observe(contentRef.current) + return () => ro.disconnect() + }, [activePanel]) + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + const target = e.target as HTMLElement | null + if ( + target?.closest( + '[data-slot="select-content"], [data-slot="select-item"], [data-slot="select-trigger"], [role="listbox"]' + ) + ) + return + if (containerRef.current && !containerRef.current.contains(target)) { + setActivePanel(null) + } + } + if (activePanel !== null) { + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + } + }, [activePanel]) + + const handleRotateStart = useCallback(() => { + rotationRef.current -= 20 + void rotateControls.start({ + rotate: rotationRef.current, + transition: { damping: 25, stiffness: 400, type: "spring" }, + }) + }, [rotateControls]) + + const handleRotateEnd = useCallback(() => { + rotationRef.current -= 340 + void rotateControls.start({ + rotate: rotationRef.current, + transition: { damping: 18, stiffness: 200, type: "spring" }, + }) + }, [rotateControls]) + + const handleSettingsToggle = useCallback(() => { + // No-op: PopoverTrigger opens the StreamPanel popover directly + setActivePanel(null) + }, []) + + type ToolbarItem = { + active: boolean + icon: React.ElementType + iconStyle?: string + id: string + label: string + strokeWidth?: number + type: "action" | "tab" + } + + const items: ToolbarItem[] = [ + { + active: expanded, + icon: expanded ? Minimize2Icon : Maximize2Icon, + id: "expand", + label: expanded ? "Collapse" : "Expand", + strokeWidth: 2.2, + type: "action", + }, + { + active: false, + icon: RotateCcwIcon, + id: "refresh", + label: "Refresh", + strokeWidth: 2.5, + type: "action", + }, + { + active: false, + icon: theme === "dark" ? MoonIcon : SunIcon, + iconStyle: "fill-current", + id: "theme", + label: theme === "dark" ? "Light mode" : "Dark mode", + strokeWidth: 2.5, + type: "action", + }, + // { + // active: activePanel === "settings", + // icon: Settings2Icon, + // iconStyle: "fill-current size-4.5", + // id: "settings", + // label: "Settings", + // strokeWidth: 1.5, + // type: "tab", + // }, + ...(codeUrl + ? [ + { + active: false, + icon: CodeXmlIcon, + iconStyle: "size-4.5", + id: "code", + label: "Source Code", + strokeWidth: 2.8, + type: "action" as const, + }, + ] + : []), + ] + + const dividerIndex = items.findIndex((i) => i.type === "tab") + + const labelWidths = items.reduce( + (acc, item) => acc + (item.active ? item.label.length * 7 + 24 : 0), + 0 + ) + const baseWidth = + 12 + + 36 * items.length + + 4 * Math.max(items.length - 1, 0) + + (dividerIndex !== -1 ? 9 : 0) + const pillWidth = baseWidth + labelWidths + + const pillHeight = + activePanel === null ? 52 : contentHeight > 0 ? contentHeight + 52 : 52 + + function handleItemClick(item: ToolbarItem) { + if (item.id === "refresh") { + onReload() + void handleRotateEnd() + return + } + if (item.type === "tab") { + handleSettingsToggle() + } else { + if (item.id === "expand") onExpandToggle() + if (item.id === "theme") onThemeToggle() + if (item.id === "code" && codeUrl) { + window.open(codeUrl, "_blank", "noopener,noreferrer") + } + } + } + + return ( +
+
+ + +
+ +
+
+ {items.map((item, index) => { + const Icon = item.icon + const isActive = item.active + const sharedClassName = cn( + "flex h-9 cursor-pointer items-center rounded-2xl text-sm font-medium transition-colors duration-300", + isActive + ? "bg-foreground/4 text-foreground" + : ` + text-muted-foreground + hover:bg-muted hover:text-foreground + `, + item.id === "code" && "hover:bg-accent/10" + ) + const sharedAnimate = { + gap: isActive ? ".5rem" : "0", + paddingLeft: isActive ? "1rem" : ".5rem", + paddingRight: isActive ? "1rem" : ".5rem", + } + + if (item.id === "settings") { + return ( + + {dividerIndex === index && ( +
+ )} + handleSettingsToggle()} + transition={CONTENT_TRANSITION} + /> + } + > + + + + + {isActive && ( + + {item.label} + + )} + + + + ) + } + + return ( + + {dividerIndex === index && ( +
+ )} + handleItemClick(item)} + onTapStart={ + item.id === "refresh" ? handleRotateStart : undefined + } + transition={CONTENT_TRANSITION} + type="button" + > + + + + + {isActive && ( + + {item.label} + + )} + + + + ) + })} +
+
+ + +
+
+ ) +} + +export { StreamPanelProvider } diff --git a/apps/www/components/blocks/blocks-sidebar.tsx b/apps/www/components/blocks/blocks-sidebar.tsx deleted file mode 100644 index 52b2b31..0000000 --- a/apps/www/components/blocks/blocks-sidebar.tsx +++ /dev/null @@ -1,203 +0,0 @@ -"use client" - -import type { ReactNode } from "react" - -import { motion } from "motion/react" -import Link from "next/link" -import { usePathname } from "next/navigation" - -import { - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar" -import { cn } from "@/lib/utils" - -import { Tooltip, TooltipTrigger } from "../ui/tooltip" - -const SIDEBAR_EASE = [0.23, 0.88, 0.26, 0.92] as const -const SIDEBAR_DURATION = 0.35 - -type BlocksSidebarItem = { - description?: string - href: string - icon?: ReactNode - status?: string - title: string -} - -export function BlocksSidebar({ items }: { items: BlocksSidebarItem[] }) { - const pathname = usePathname() - const { isMobile, open, openMobile } = useSidebar() - const isExpanded = isMobile ? openMobile : open - - return ( - <> - - - - {/* - Toggle{" "} - - Cmd - B - - */} - - -
- {/* -
-

- Blocks -

- - {items.length} - -
-
*/} - - - - Browse - - - {items.map((item) => ( - - ))} - - - - -
-
- - ) -} - -function FloatingSidebarToggle() { - const { isMobile, open, openMobile, toggleSidebar } = useSidebar() - const isExpanded = isMobile ? openMobile : open - - return ( - - - - - - ) -} - -function SidebarDismissLayer() { - const { isMobile, open, setOpen } = useSidebar() - - if (isMobile || !open) { - return null - } - - return ( - +
+ +
+
+ ) +} diff --git a/apps/www/components/blocks/info-pane.tsx b/apps/www/components/blocks/info-pane.tsx new file mode 100644 index 0000000..5691542 --- /dev/null +++ b/apps/www/components/blocks/info-pane.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react" + +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, +} from "fumadocs-ui/page" + +export function BlockInfoPane({ + content, + description, + title, +}: { + content: ReactNode + description?: string + title: string +}) { + return ( +
+ + {title} + {description} + {content} + +
+ ) +} diff --git a/apps/www/components/blocks/preview-background.tsx b/apps/www/components/blocks/preview-background.tsx new file mode 100644 index 0000000..247152c --- /dev/null +++ b/apps/www/components/blocks/preview-background.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react" + +import { LimeplayLogo } from "@/registry/default/ui/limeplay-logo" + +export function BlockPreviewPane({ children }: { children: ReactNode }) { + return ( +
+
+ + {children} +
+
+ ) +} diff --git a/apps/www/components/blocks/preview-pane.tsx b/apps/www/components/blocks/preview-pane.tsx new file mode 100644 index 0000000..a06cbb2 --- /dev/null +++ b/apps/www/components/blocks/preview-pane.tsx @@ -0,0 +1,136 @@ +"use client" + +import { motion } from "motion/react" +import React, { useCallback, useEffect, useRef, useState } from "react" + +import { StreamPanel, StreamPanelProvider } from "@/components/stream-panel" +import { useThemeToggle } from "@/components/theme-toggle" + +import { BlockToolbar } from "./block-toolbar" + +export function BlockPreviewWithToolbar({ + children, + codeUrl, +}: { + children: React.ReactNode + codeUrl?: string +}) { + const [expanded, setExpanded] = useState(false) + const { isDark, toggleTheme } = useThemeToggle({ + blur: false, + start: "top-right", + variant: "circle", + }) + const theme = isDark ? "dark" : "light" + const [reloadKey, setReloadKey] = useState(0) + const panelRef = useRef(null) + const [panelRect, setPanelRect] = useState({ + height: 0, + left: 0, + top: 0, + width: 0, + }) + + useEffect(() => { + if (!panelRef.current) return + const update = () => { + if (!panelRef.current) return + const rect = panelRef.current.getBoundingClientRect() + setPanelRect({ + height: rect.height, + left: rect.left, + top: rect.top, + width: rect.width, + }) + } + update() + const ro = new ResizeObserver(update) + ro.observe(panelRef.current) + window.addEventListener("resize", update) + return () => { + ro.disconnect() + window.removeEventListener("resize", update) + } + }, []) + + const handleExpandToggle = useCallback(() => { + setExpanded((e) => !e) + }, []) + + const handleReload = useCallback(() => { + setReloadKey((k) => k + 1) + }, []) + + const expandedInset = 16 + const [windowSize, setWindowSize] = useState({ height: 0, width: 0 }) + + useEffect(() => { + const update = () => + setWindowSize({ height: window.innerHeight, width: window.innerWidth }) + update() + window.addEventListener("resize", update) + return () => window.removeEventListener("resize", update) + }, []) + + // DEV: This controls the breadcumbs to be hidden when preview is expanded + useEffect(() => { + document.documentElement.dataset.blockPreviewExpanded = expanded + ? "true" + : "false" + return () => { + document.documentElement.dataset.blockPreviewExpanded = "false" + } + }, [expanded]) + + const collapsedStyle = { + height: panelRect.height, + left: panelRect.left, + top: panelRect.top, + width: panelRect.width, + } + + const expandedStyle = { + height: windowSize.height - expandedInset * 2, + left: expandedInset, + top: expandedInset, + width: windowSize.width - expandedInset * 2, + } + + return ( + +
+ +
+
+
+ {children} +
+ + +
+
+
+ + + ) +} diff --git a/apps/www/components/blocks/sidebar.tsx b/apps/www/components/blocks/sidebar.tsx new file mode 100644 index 0000000..e3a474a --- /dev/null +++ b/apps/www/components/blocks/sidebar.tsx @@ -0,0 +1,165 @@ +"use client" + +import type { ReactNode } from "react" + +import { motion } from "motion/react" +import Link from "next/link" +import { usePathname } from "next/navigation" + +import { useSidebar } from "@/components/ui/sidebar" +import { cn } from "@/lib/utils" + +const SIDEBAR_EASE = [0.23, 0.88, 0.26, 0.92] as const +const SIDEBAR_DURATION = 0.35 + +type BlocksSidebarItem = { + description?: string + href: string + icon?: ReactNode + status?: string + title: string +} + +export function BlocksSidebar({ items }: { items: BlocksSidebarItem[] }) { + const pathname = usePathname() + const { isMobile, open, openMobile } = useSidebar() + const isExpanded = isMobile ? openMobile : open + + return ( + <> + + + + + + + + ) +} + +function GradualBlur() { + return ( +