├── bun.lockb ├── app ├── favicon.ico ├── page.tsx ├── layout.tsx └── globals.css ├── public ├── icons │ ├── compositor.jpg │ ├── 21Arachnid.avif │ ├── LSilverPaint.avif │ ├── Paint_Black.avif │ ├── Paint_Blue.avif │ ├── Paint_White.avif │ ├── Red_Paint_R2.avif │ ├── Interior_Black.avif │ ├── Interior_Cream.avif │ ├── Interior_White.avif │ ├── Paint_StealthGrey.avif │ ├── ui_swat_s-wheel-y.png │ └── ui_swat_whl_tempest.avif ├── model_3_exterior_stealth_grey_main.jpg ├── model_3_exterior_stealth_grey_side.jpg ├── model_3_exterior_stealth_grey_perspective_back.jpg └── model_s_exterior_stealth_grey_tempest_wheels_main.jpg ├── next.config.mjs ├── postcss.config.mjs ├── lib └── utils.ts ├── .eslintrc.json ├── components ├── ui │ ├── skeleton.tsx │ ├── collapsible.tsx │ ├── label.tsx │ ├── separator.tsx │ ├── input.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── badge.tsx │ ├── tooltip.tsx │ ├── toggle.tsx │ ├── avatar.tsx │ ├── toggle-group.tsx │ ├── button.tsx │ ├── card.tsx │ ├── breadcrumb.tsx │ ├── animated-grid-pattern.tsx │ ├── sheet.tsx │ ├── context-menu.tsx │ ├── dropdown-menu.tsx │ └── sidebar.tsx ├── desktop │ ├── Sidebar.tsx │ ├── EmptySpaceContextMenu.tsx │ ├── Tree.tsx │ ├── DragAndDropArea.tsx │ ├── DesktopWrapper.tsx │ ├── DraggableWindow.tsx │ ├── ItemModal.tsx │ └── DesktopItem.tsx └── magicui │ ├── dot-pattern.tsx │ ├── blur-fade.tsx │ ├── blur-fade-text.tsx │ └── dock.tsx ├── context └── desktop.tsx ├── components.json ├── .gitignore ├── hooks ├── use-mobile.tsx └── useDesktop.tsx ├── tsconfig.json ├── types └── desktop.tsx ├── package.json ├── README.md ├── config └── desktop.tsx └── tailwind.config.ts /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/bun.lockb -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/icons/compositor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/compositor.jpg -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /public/icons/21Arachnid.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/21Arachnid.avif -------------------------------------------------------------------------------- /public/icons/LSilverPaint.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/LSilverPaint.avif -------------------------------------------------------------------------------- /public/icons/Paint_Black.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/Paint_Black.avif -------------------------------------------------------------------------------- /public/icons/Paint_Blue.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/Paint_Blue.avif -------------------------------------------------------------------------------- /public/icons/Paint_White.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/Paint_White.avif -------------------------------------------------------------------------------- /public/icons/Red_Paint_R2.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/Red_Paint_R2.avif -------------------------------------------------------------------------------- /public/icons/Interior_Black.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/Interior_Black.avif -------------------------------------------------------------------------------- /public/icons/Interior_Cream.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/Interior_Cream.avif -------------------------------------------------------------------------------- /public/icons/Interior_White.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/Interior_White.avif -------------------------------------------------------------------------------- /public/icons/Paint_StealthGrey.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/Paint_StealthGrey.avif -------------------------------------------------------------------------------- /public/icons/ui_swat_s-wheel-y.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/ui_swat_s-wheel-y.png -------------------------------------------------------------------------------- /public/icons/ui_swat_whl_tempest.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/icons/ui_swat_whl_tempest.avif -------------------------------------------------------------------------------- /public/model_3_exterior_stealth_grey_main.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/model_3_exterior_stealth_grey_main.jpg -------------------------------------------------------------------------------- /public/model_3_exterior_stealth_grey_side.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/model_3_exterior_stealth_grey_side.jpg -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import DesktopWrapper from "@/components/desktop/DesktopWrapper"; 2 | 3 | export default function Home() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /public/model_3_exterior_stealth_grey_perspective_back.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/model_3_exterior_stealth_grey_perspective_back.jpg -------------------------------------------------------------------------------- /public/model_s_exterior_stealth_grey_tempest_wheels_main.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowfound/desktop-simulator/HEAD/public/model_s_exterior_stealth_grey_tempest_wheels_main.jpg -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "plugins": ["@typescript-eslint"], 4 | "rules": { 5 | "@typescript-eslint/no-unused-vars": "off", 6 | "import/no-unresolved": "error", 7 | "import/named": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 | 12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 4 | 5 | const Collapsible = CollapsiblePrimitive.Root 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 10 | 11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 12 | -------------------------------------------------------------------------------- /context/desktop.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import type { DesktopContextValue } from "@/hooks/useDesktop"; 3 | 4 | export const DesktopContext = createContext(null); 5 | 6 | export const useDesktopContext = () => { 7 | const context = useContext(DesktopContext); 8 | if (!context) { 9 | throw new Error("useDesktopContext must be used within a DesktopProvider"); 10 | } 11 | return context; 12 | }; 13 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /types/desktop.tsx: -------------------------------------------------------------------------------- 1 | export interface Item { 2 | id: string; 3 | name: string; 4 | type: "file" | "folder"; 5 | content?: Item[]; 6 | link?: string; 7 | locationId: string | null; 8 | path: string; 9 | } 10 | 11 | export interface WindowItem { 12 | id: string; 13 | itemId: string; 14 | item: Item; 15 | position: { x: number; y: number }; 16 | size: { width: number; height: number }; 17 | isMinimized: boolean; 18 | } 19 | 20 | export interface ModalState { 21 | open: boolean; 22 | type: "new" | "edit" | "rename" | "copy" | "cut" | null; 23 | itemType: "file" | "folder" | null; 24 | locationId: string | null; 25 | item: Item | null; 26 | } 27 | 28 | export interface DropResult { 29 | id: string; 30 | } 31 | 32 | export interface ClipboardItem { 33 | item: Item; 34 | operation: "copy" | "cut"; 35 | } 36 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner" 5 | 6 | type ToasterProps = React.ComponentProps 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | 28 | ) 29 | } 30 | 31 | export { Toaster } 32 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | 3 | import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; 4 | import type { Metadata } from "next"; 5 | import "./globals.css"; 6 | import { ThemeProvider } from "next-themes"; 7 | import { Toaster } from "@/components/ui/sonner"; 8 | import { Inter } from "next/font/google"; 9 | 10 | const inter = Inter({ subsets: ["latin"] }); 11 | 12 | export const metadata: Metadata = { 13 | title: "Desktop Simulator", 14 | description: "Drag and drop folders and files on a desktop simulator.", 15 | }; 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: React.ReactNode; 21 | }>) { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | {children} 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )); 27 | Switch.displayName = SwitchPrimitives.Root.displayName; 28 | 29 | export { Switch }; 30 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 | 33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 19 | 28 | 29 | )) 30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 31 | 32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 33 | -------------------------------------------------------------------------------- /components/desktop/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | import * as React from "react"; 4 | import { File, Folder } from "lucide-react"; 5 | 6 | import { 7 | Sidebar, 8 | SidebarContent, 9 | SidebarGroup, 10 | SidebarGroupContent, 11 | SidebarGroupLabel, 12 | SidebarMenu, 13 | SidebarMenuBadge, 14 | SidebarMenuButton, 15 | SidebarMenuItem, 16 | SidebarMenuSub, 17 | SidebarRail, 18 | } from "@/components/ui/sidebar"; 19 | import Tree from "@/components/desktop/Tree"; 20 | import type { Item } from "@/types/desktop"; 21 | 22 | export default function AppSidebar({ 23 | items, 24 | }: React.ComponentProps & { items: Item[] }) { 25 | return ( 26 | 27 | 28 | 29 | Files 30 | 31 | 32 | {items.map((item) => ( 33 | 34 | ))} 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /components/magicui/dot-pattern.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { useId } from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | interface DotPatternProps { 7 | width?: any; 8 | height?: any; 9 | x?: any; 10 | y?: any; 11 | cx?: any; 12 | cy?: any; 13 | cr?: any; 14 | className?: string; 15 | [key: string]: any; 16 | } 17 | export function DotPattern({ 18 | width = 16, 19 | height = 16, 20 | x = 0, 21 | y = 0, 22 | cx = 1, 23 | cy = 1, 24 | cr = 1, 25 | className, 26 | ...props 27 | }: DotPatternProps) { 28 | const id = useId(); 29 | 30 | return ( 31 | 39 | 40 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | 57 | export default DotPattern; 58 | -------------------------------------------------------------------------------- /components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TogglePrimitive from "@radix-ui/react-toggle" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const toggleVariants = cva( 10 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", 11 | { 12 | variants: { 13 | variant: { 14 | default: "bg-transparent", 15 | outline: 16 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", 17 | }, 18 | size: { 19 | default: "h-9 px-3", 20 | sm: "h-8 px-2", 21 | lg: "h-10 px-3", 22 | }, 23 | }, 24 | defaultVariants: { 25 | variant: "default", 26 | size: "default", 27 | }, 28 | } 29 | ) 30 | 31 | const Toggle = React.forwardRef< 32 | React.ElementRef, 33 | React.ComponentPropsWithoutRef & 34 | VariantProps 35 | >(({ className, variant, size, ...props }, ref) => ( 36 | 41 | )) 42 | 43 | Toggle.displayName = TogglePrimitive.Root.displayName 44 | 45 | export { Toggle, toggleVariants } 46 | -------------------------------------------------------------------------------- /components/desktop/EmptySpaceContextMenu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { File, Folder } from "lucide-react"; 5 | import { 6 | ContextMenu, 7 | ContextMenuTrigger, 8 | ContextMenuContent, 9 | ContextMenuItem, 10 | } from "@/components/ui/context-menu"; 11 | import { useDesktopContext } from "@/context/desktop"; 12 | 13 | interface EmptySpaceContextMenuProps { 14 | locationId?: string | null; 15 | children: React.ReactNode; 16 | } 17 | 18 | export const EmptySpaceContextMenu: React.FC = ({ 19 | locationId, 20 | children, 21 | }) => { 22 | const { handleCreateFile, handleCreateFolder, clipboard, pasteItem } = 23 | useDesktopContext(); 24 | 25 | return ( 26 | 27 | 28 | e.stopPropagation()}>{children} 29 | 30 | 31 | handleCreateFile(locationId)}> 32 | 33 | New File 34 | 35 | handleCreateFolder(locationId)}> 36 | 37 | New Folder 38 | 39 | pasteItem(locationId)} 41 | disabled={!clipboard} 42 | > 43 | 44 | Paste 45 | 46 | 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /components/desktop/Tree.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | import { 3 | SidebarMenuItem, 4 | SidebarMenuButton, 5 | SidebarMenuSub, 6 | } from "@/components/ui/sidebar"; 7 | import { 8 | Collapsible, 9 | CollapsibleContent, 10 | CollapsibleTrigger, 11 | } from "@/components/ui/collapsible"; 12 | import { ChevronRight, Folder, File } from "lucide-react"; 13 | 14 | import type { Item } from "@/types/desktop"; 15 | 16 | interface TreeProps { 17 | item: Item; 18 | } 19 | 20 | const Tree: React.FC = memo(({ item }) => { 21 | if (item.type === "folder" && item.content) { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | {item.name} 30 | 31 | 32 | 33 | 34 | {item.content.map((subItem) => ( 35 | 36 | ))} 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | return ( 45 | 46 | 47 | {item.name} 48 | 49 | ); 50 | }); 51 | 52 | // Assigning displayName to the memoized component 53 | Tree.displayName = "Tree"; 54 | 55 | export default Tree; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "desktop-simulator", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-avatar": "^1.1.1", 13 | "@radix-ui/react-collapsible": "^1.1.1", 14 | "@radix-ui/react-context-menu": "^2.2.2", 15 | "@radix-ui/react-dialog": "^1.1.2", 16 | "@radix-ui/react-dropdown-menu": "^2.1.2", 17 | "@radix-ui/react-icons": "^1.3.1", 18 | "@radix-ui/react-label": "^2.1.0", 19 | "@radix-ui/react-separator": "^1.1.0", 20 | "@radix-ui/react-slot": "^1.1.0", 21 | "@radix-ui/react-switch": "^1.1.1", 22 | "@radix-ui/react-toggle": "^1.1.0", 23 | "@radix-ui/react-toggle-group": "^1.1.0", 24 | "@radix-ui/react-tooltip": "^1.1.3", 25 | "class-variance-authority": "^0.7.0", 26 | "clsx": "^2.1.1", 27 | "framer-motion": "^11.11.11", 28 | "lucide-react": "^0.454.0", 29 | "next": "14.2.16", 30 | "next-themes": "^0.4.3", 31 | "react": "^18", 32 | "react-dnd": "^16.0.1", 33 | "react-dnd-html5-backend": "^16.0.1", 34 | "react-dom": "^18", 35 | "sonner": "^1.7.0", 36 | "tailwind-merge": "^2.5.4", 37 | "tailwindcss-animate": "^1.0.7", 38 | "uuid": "^11.0.2" 39 | }, 40 | "devDependencies": { 41 | "@types/node": "^20", 42 | "@types/react": "^18", 43 | "@types/react-dom": "^18", 44 | "eslint": "^8", 45 | "eslint-config-next": "14.2.16", 46 | "postcss": "^8", 47 | "tailwindcss": "^3.4.1", 48 | "typescript": "^5" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |  2 | 3 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 4 | 5 | ## Getting Started 6 | 7 | First, run the development server: 8 | 9 | ```bash 10 | npm run dev 11 | # or 12 | yarn dev 13 | # or 14 | pnpm dev 15 | # or 16 | bun dev 17 | ``` 18 | 19 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 20 | 21 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 22 | 23 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 24 | 25 | ## Learn More 26 | 27 | To learn more about Next.js, take a look at the following resources: 28 | 29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 31 | 32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 33 | 34 | ## Deploy on Vercel 35 | 36 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 37 | 38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 39 | -------------------------------------------------------------------------------- /components/magicui/blur-fade.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AnimatePresence, motion, useInView, Variants } from "framer-motion"; 4 | import { useRef } from "react"; 5 | 6 | interface BlurFadeProps { 7 | children: React.ReactNode; 8 | className?: string; 9 | variant?: { 10 | hidden: { y: number }; 11 | visible: { y: number }; 12 | }; 13 | duration?: number; 14 | delay?: number; 15 | yOffset?: number; 16 | inView?: boolean; 17 | inViewMargin?: string; 18 | blur?: string; 19 | } 20 | const BlurFade = ({ 21 | children, 22 | className, 23 | variant, 24 | duration = 0.4, 25 | delay = 0, 26 | yOffset = 6, 27 | inView = false, 28 | inViewMargin = "-50px", 29 | blur = "6px", 30 | }: BlurFadeProps) => { 31 | const ref = useRef(null); 32 | const inViewResult = useInView(ref, { 33 | once: true, 34 | margin: inViewMargin as any, 35 | }); 36 | const isInView = !inView || inViewResult; 37 | const defaultVariants: Variants = { 38 | hidden: { y: yOffset, opacity: 0, filter: `blur(${blur})` }, 39 | visible: { y: -yOffset, opacity: 1, filter: `blur(0px)` }, 40 | }; 41 | const combinedVariants = variant || defaultVariants; 42 | return ( 43 | 44 | 57 | {children} 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default BlurFade; 64 | -------------------------------------------------------------------------------- /components/desktop/DragAndDropArea.tsx: -------------------------------------------------------------------------------- 1 | import { useDrop } from "react-dnd"; 2 | import { DesktopItem } from "@/components/desktop/DesktopItem"; 3 | import type { Item } from "@/types/desktop"; 4 | import { useDesktopContext } from "@/context/desktop"; 5 | 6 | interface DragDropAreaProps { 7 | items: Item[]; 8 | locationId?: string | null; 9 | parentPath: string; 10 | } 11 | 12 | export const DragDropArea: React.FC = ({ 13 | items, 14 | locationId = null, 15 | parentPath, 16 | }) => { 17 | const { 18 | pasteItem, 19 | openWindow, 20 | setModalState, 21 | deleteItem, 22 | handleCopy, 23 | handleCut, 24 | } = useDesktopContext(); 25 | 26 | const [{ isOver, canDrop }, drop] = useDrop({ 27 | accept: "ITEM", 28 | drop: () => ({ id: locationId ?? null }), 29 | collect: (monitor) => ({ 30 | isOver: monitor.isOver({ shallow: true }), 31 | canDrop: monitor.canDrop(), 32 | }), 33 | }); 34 | 35 | return ( 36 | { 38 | drop(node as unknown as HTMLElement); 39 | }} 40 | className="relative w-full h-full" 41 | > 42 | {isOver && canDrop && ( 43 | 44 | )} 45 | 52 | {items.map((item) => ( 53 | 54 | ))} 55 | 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /components/ui/toggle-group.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" 5 | import { type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | import { toggleVariants } from "@/components/ui/toggle" 9 | 10 | const ToggleGroupContext = React.createContext< 11 | VariantProps 12 | >({ 13 | size: "default", 14 | variant: "default", 15 | }) 16 | 17 | const ToggleGroup = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef & 20 | VariantProps 21 | >(({ className, variant, size, children, ...props }, ref) => ( 22 | 27 | 28 | {children} 29 | 30 | 31 | )) 32 | 33 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName 34 | 35 | const ToggleGroupItem = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef & 38 | VariantProps 39 | >(({ className, children, variant, size, ...props }, ref) => { 40 | const context = React.useContext(ToggleGroupContext) 41 | 42 | return ( 43 | 54 | {children} 55 | 56 | ) 57 | }) 58 | 59 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName 60 | 61 | export { ToggleGroup, ToggleGroupItem } 62 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 | 17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 | 29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 | 41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 | 53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 | 61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 | 73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /config/desktop.tsx: -------------------------------------------------------------------------------- 1 | import type { Item } from "@/types/desktop"; 2 | 3 | // Helper function to generate a random ID 4 | const generateId = (): string => { 5 | return Math.random().toString(36).substr(2, 9); 6 | }; 7 | 8 | // Helper function to generate a random name 9 | const generateName = (type: "folder" | "file"): string => { 10 | const prefixes = { 11 | folder: ["Folder", "Directory", "Project", "Archive"], 12 | file: ["Document", "Image", "Note", "Report"], 13 | }; 14 | const prefix = 15 | prefixes[type][Math.floor(Math.random() * prefixes[type].length)]; 16 | return `${prefix}_${Math.floor(Math.random() * 1000)}`; 17 | }; 18 | 19 | // Helper function to generate a random link (for files) 20 | const generateLink = (): string => { 21 | return `https://example.com/${Math.random().toString(36).substr(2, 9)}`; 22 | }; 23 | 24 | // Recursive function to generate a random file system structure 25 | const generateFileSystem = ( 26 | depth: number, 27 | maxDepth: number, 28 | minItemsPerFolder: number, 29 | maxItemsPerFolder: number, 30 | locationId: string | null = null, 31 | parentPath: string = "/desktop" 32 | ): Item[] => { 33 | if (depth > maxDepth) return []; 34 | 35 | const items: Item[] = []; 36 | const numItems = 37 | Math.floor(Math.random() * maxItemsPerFolder) + minItemsPerFolder; 38 | 39 | for (let i = 0; i < numItems; i++) { 40 | const isFolder = Math.random() > 0.5; // 50% chance to be a folder 41 | const id = generateId(); 42 | const name = generateName(isFolder ? "folder" : "file"); 43 | const path = `${parentPath}/${name}`; 44 | 45 | const item: Item = { 46 | id, 47 | name, 48 | type: isFolder ? "folder" : "file", 49 | locationId, 50 | path, 51 | }; 52 | 53 | if (isFolder) { 54 | item.content = generateFileSystem( 55 | depth + 1, 56 | maxDepth, 57 | minItemsPerFolder, 58 | maxItemsPerFolder, 59 | id, 60 | path 61 | ); 62 | } else { 63 | item.link = generateLink(); 64 | } 65 | 66 | items.push(item); 67 | } 68 | 69 | return items; 70 | }; 71 | 72 | // Function to generate the initial file system with random data 73 | export const generateInitialItems = (): Item[] => { 74 | return generateFileSystem(2, 6, 4, 8); // Adjust depth and maxItemsPerFolder as needed 75 | }; 76 | 77 | // Example usage 78 | export const initialItems: Item[] = generateInitialItems(); 79 | -------------------------------------------------------------------------------- /components/magicui/blur-fade-text.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { AnimatePresence, motion, Variants } from "framer-motion"; 5 | import { useMemo } from "react"; 6 | 7 | interface BlurFadeTextProps { 8 | text: string; 9 | className?: string; 10 | variant?: { 11 | hidden: { y: number }; 12 | visible: { y: number }; 13 | }; 14 | duration?: number; 15 | characterDelay?: number; 16 | delay?: number; 17 | yOffset?: number; 18 | animateByCharacter?: boolean; 19 | } 20 | const BlurFadeText = ({ 21 | text, 22 | className, 23 | variant, 24 | characterDelay = 0.03, 25 | delay = 0, 26 | yOffset = 8, 27 | animateByCharacter = false, 28 | }: BlurFadeTextProps) => { 29 | const defaultVariants: Variants = { 30 | hidden: { y: yOffset, opacity: 0, filter: "blur(8px)" }, 31 | visible: { y: -yOffset, opacity: 1, filter: "blur(0px)" }, 32 | }; 33 | const combinedVariants = variant || defaultVariants; 34 | const characters = useMemo(() => Array.from(text), [text]); 35 | 36 | if (animateByCharacter) { 37 | return ( 38 | 39 | 40 | {characters.map((char, i) => ( 41 | 55 | {char} 56 | 57 | ))} 58 | 59 | 60 | ); 61 | } 62 | 63 | return ( 64 | 65 | 66 | 78 | {text} 79 | 80 | 81 | 82 | ); 83 | }; 84 | 85 | export default BlurFadeText; 86 | -------------------------------------------------------------------------------- /components/desktop/DesktopWrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { DndProvider } from "react-dnd"; 5 | import { HTML5Backend } from "react-dnd-html5-backend"; 6 | import { DesktopContext } from "@/context/desktop"; 7 | import AppSidebar from "@/components/desktop/Sidebar"; 8 | import { DragDropArea } from "@/components/desktop/DragAndDropArea"; 9 | import { ItemModal } from "@/components/desktop/ItemModal"; 10 | import { DraggableWindow } from "@/components/desktop/DraggableWindow"; 11 | import { EmptySpaceContextMenu } from "@/components/desktop/EmptySpaceContextMenu"; 12 | import DotPattern from "@/components/magicui/dot-pattern"; 13 | import { cn } from "@/lib/utils"; 14 | import { useDesktop } from "@/hooks/useDesktop"; 15 | 16 | const DesktopWrapper: React.FC = () => { 17 | const desktopValues = useDesktop(); 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | item.locationId === null 35 | )} 36 | parentPath="/desktop" 37 | /> 38 | 39 | {desktopValues.windows.map((windowItem) => ( 40 | 41 | 42 | 47 | 48 | 49 | ))} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | }; 59 | 60 | export default DesktopWrapper; 61 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .text-balance { 7 | text-wrap: balance; 8 | } 9 | } 10 | 11 | * { 12 | -webkit-user-select: none; /* Safari */ 13 | -moz-user-select: none; /* Firefox */ 14 | -ms-user-select: none; /* IE10+/Edge */ 15 | user-select: none; /* Standard */ 16 | } 17 | 18 | .context-menu { 19 | z-index: 1002; /* Higher than modals */ 20 | } 21 | 22 | @layer base { 23 | :root { 24 | --background: 0 0% 100%; 25 | --foreground: 0 0% 3.9%; 26 | --card: 0 0% 100%; 27 | --card-foreground: 0 0% 3.9%; 28 | --popover: 0 0% 100%; 29 | --popover-foreground: 0 0% 3.9%; 30 | --primary: 0 0% 9%; 31 | --primary-foreground: 0 0% 98%; 32 | --secondary: 0 0% 96.1%; 33 | --secondary-foreground: 0 0% 9%; 34 | --muted: 0 0% 96.1%; 35 | --muted-foreground: 0 0% 45.1%; 36 | --accent: 0 0% 96.1%; 37 | --accent-foreground: 0 0% 9%; 38 | --destructive: 0 84.2% 60.2%; 39 | --destructive-foreground: 0 0% 98%; 40 | --border: 0 0% 89.8%; 41 | --input: 0 0% 89.8%; 42 | --ring: 0 0% 3.9%; 43 | --chart-1: 12 76% 61%; 44 | --chart-2: 173 58% 39%; 45 | --chart-3: 197 37% 24%; 46 | --chart-4: 43 74% 66%; 47 | --chart-5: 27 87% 67%; 48 | --radius: 0.5rem; 49 | --sidebar-background: 0 0% 98%; 50 | --sidebar-foreground: 240 5.3% 26.1%; 51 | --sidebar-primary: 240 5.9% 10%; 52 | --sidebar-primary-foreground: 0 0% 98%; 53 | --sidebar-accent: 240 4.8% 95.9%; 54 | --sidebar-accent-foreground: 240 5.9% 10%; 55 | --sidebar-border: 220 13% 91%; 56 | --sidebar-ring: 217.2 91.2% 59.8%; 57 | } 58 | .dark { 59 | --background: 0 0% 3.9%; 60 | --foreground: 0 0% 98%; 61 | --card: 0 0% 0%; 62 | --card-foreground: 0 0% 98%; 63 | --popover: 0 0% 3.9%; 64 | --popover-foreground: 0 0% 98%; 65 | --primary: 0 0% 98%; 66 | --primary-foreground: 0 0% 9%; 67 | --secondary: 0 0% 14.9%; 68 | --secondary-foreground: 0 0% 98%; 69 | --muted: 0 0% 14.9%; 70 | --muted-foreground: 0 0% 63.9%; 71 | --accent: 0 0% 14.9%; 72 | --accent-foreground: 0 0% 98%; 73 | --destructive: 0 62.8% 30.6%; 74 | --destructive-foreground: 0 0% 98%; 75 | --border: 0 0% 14.9%; 76 | --input: 0 0% 14.9%; 77 | --ring: 0 0% 83.1%; 78 | --chart-1: 220 70% 50%; 79 | --chart-2: 160 60% 45%; 80 | --chart-3: 30 80% 55%; 81 | --chart-4: 280 65% 60%; 82 | --chart-5: 340 75% 55%; 83 | --sidebar-background: 240 5.9% 10%; 84 | --sidebar-foreground: 240 4.8% 95.9%; 85 | --sidebar-primary: 224.3 76.3% 48%; 86 | --sidebar-primary-foreground: 0 0% 100%; 87 | --sidebar-accent: 240 3.7% 15.9%; 88 | --sidebar-accent-foreground: 240 4.8% 95.9%; 89 | --sidebar-border: 240 3.7% 15.9%; 90 | --sidebar-ring: 217.2 91.2% 59.8%; 91 | } 92 | } 93 | 94 | @layer base { 95 | * { 96 | @apply border-border; 97 | } 98 | body { 99 | @apply bg-background text-foreground; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /components/desktop/DraggableWindow.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect } from "react"; 2 | import { Minimize, X } from "lucide-react"; 3 | import type { WindowItem } from "@/types/desktop"; 4 | import { useDesktopContext } from "@/context/desktop"; 5 | 6 | interface DraggableWindowProps { 7 | windowItem: WindowItem; 8 | children: React.ReactNode; 9 | } 10 | 11 | export const DraggableWindow: React.FC = ({ 12 | windowItem, 13 | children, 14 | }) => { 15 | const { closeWindow, minimizeWindow, moveWindow } = useDesktopContext(); 16 | const [isDragging, setIsDragging] = useState(false); 17 | const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); 18 | 19 | const handleMouseDown = (e: React.MouseEvent) => { 20 | setIsDragging(true); 21 | setDragStart({ 22 | x: e.clientX - windowItem.position.x, 23 | y: e.clientY - windowItem.position.y, 24 | }); 25 | }; 26 | 27 | const handleMouseMove = useCallback( 28 | (e: MouseEvent) => { 29 | if (isDragging) { 30 | moveWindow(windowItem.id, { 31 | x: e.clientX - dragStart.x, 32 | y: e.clientY - dragStart.y, 33 | }); 34 | } 35 | }, 36 | [isDragging, dragStart, moveWindow, windowItem.id] 37 | ); 38 | 39 | const handleMouseUp = useCallback(() => { 40 | setIsDragging(false); 41 | }, []); 42 | 43 | useEffect(() => { 44 | if (isDragging) { 45 | window.addEventListener("mousemove", handleMouseMove); 46 | window.addEventListener("mouseup", handleMouseUp); 47 | } 48 | return () => { 49 | window.removeEventListener("mousemove", handleMouseMove); 50 | window.removeEventListener("mouseup", handleMouseUp); 51 | }; 52 | }, [isDragging, handleMouseMove, handleMouseUp]); 53 | 54 | if (windowItem.isMinimized) return null; 55 | 56 | return ( 57 | 67 | 71 | {windowItem.item.name} 72 | 73 | minimizeWindow(windowItem.id)} 75 | className="focus:outline-none" 76 | > 77 | 78 | 79 | closeWindow(windowItem.id)} 81 | className="focus:outline-none" 82 | > 83 | 84 | 85 | 86 | 87 | 91 | {children} 92 | 93 | 94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /components/magicui/dock.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { cva, type VariantProps } from "class-variance-authority"; 5 | import { motion, useMotionValue, useSpring, useTransform } from "framer-motion"; 6 | import React, { PropsWithChildren, useRef } from "react"; 7 | 8 | export interface DockProps extends VariantProps { 9 | className?: string; 10 | magnification?: number; 11 | distance?: number; 12 | children: React.ReactNode; 13 | } 14 | 15 | const DEFAULT_MAGNIFICATION = 60; 16 | const DEFAULT_DISTANCE = 140; 17 | 18 | const dockVariants = cva( 19 | "mx-auto w-max h-full p-2 flex items-end rounded-full border" 20 | ); 21 | 22 | const Dock = React.forwardRef( 23 | ( 24 | { 25 | className, 26 | children, 27 | magnification = DEFAULT_MAGNIFICATION, 28 | distance = DEFAULT_DISTANCE, 29 | ...props 30 | }, 31 | ref 32 | ) => { 33 | const mousex = useMotionValue(Infinity); 34 | 35 | const renderChildren = () => { 36 | return React.Children.map(children, (child: any) => { 37 | if (React.isValidElement(child)) { 38 | return React.cloneElement(child, { 39 | mousex, 40 | magnification, 41 | distance, 42 | } as DockIconProps); 43 | } 44 | return child; 45 | }); 46 | }; 47 | 48 | return ( 49 | mousex.set(e.pageX)} 52 | onMouseLeave={() => mousex.set(Infinity)} 53 | {...props} 54 | className={cn(dockVariants({ className }))} 55 | > 56 | {renderChildren()} 57 | 58 | ); 59 | } 60 | ); 61 | 62 | Dock.displayName = "Dock"; 63 | 64 | export interface DockIconProps { 65 | size?: number; 66 | magnification?: number; 67 | distance?: number; 68 | mousex?: any; 69 | className?: string; 70 | children?: React.ReactNode; 71 | props?: PropsWithChildren; 72 | } 73 | 74 | const DockIcon = ({ 75 | size, 76 | magnification = DEFAULT_MAGNIFICATION, 77 | distance = DEFAULT_DISTANCE, 78 | mousex, 79 | className, 80 | children, 81 | ...props 82 | }: DockIconProps) => { 83 | const ref = useRef(null); 84 | 85 | const distanceCalc = useTransform(mousex, (val: number) => { 86 | const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 }; 87 | return val - bounds.x - bounds.width / 2; 88 | }); 89 | 90 | let widthSync = useTransform( 91 | distanceCalc, 92 | [-distance, 0, distance], 93 | [40, magnification, 40] 94 | ); 95 | 96 | let width = useSpring(widthSync, { 97 | mass: 0.1, 98 | stiffness: 150, 99 | damping: 12, 100 | }); 101 | 102 | return ( 103 | 112 | {children} 113 | 114 | ); 115 | }; 116 | 117 | DockIcon.displayName = "DockIcon"; 118 | 119 | export { Dock, DockIcon, dockVariants }; 120 | -------------------------------------------------------------------------------- /components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cn } from "@/lib/utils"; 4 | import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; 5 | 6 | const Breadcrumb = React.forwardRef< 7 | HTMLElement, 8 | React.ComponentPropsWithoutRef<"nav"> & { 9 | separator?: React.ReactNode; 10 | } 11 | >(({ ...props }, ref) => ); 12 | Breadcrumb.displayName = "Breadcrumb"; 13 | 14 | const BreadcrumbList = React.forwardRef< 15 | HTMLOListElement, 16 | React.ComponentPropsWithoutRef<"ol"> 17 | >(({ className, ...props }, ref) => ( 18 | 26 | )); 27 | BreadcrumbList.displayName = "BreadcrumbList"; 28 | 29 | const BreadcrumbItem = React.forwardRef< 30 | HTMLLIElement, 31 | React.ComponentPropsWithoutRef<"li"> 32 | >(({ className, ...props }, ref) => ( 33 | 38 | )); 39 | BreadcrumbItem.displayName = "BreadcrumbItem"; 40 | 41 | const BreadcrumbLink = React.forwardRef< 42 | HTMLAnchorElement, 43 | React.ComponentPropsWithoutRef<"a"> & { 44 | asChild?: boolean; 45 | } 46 | >(({ asChild, className, ...props }, ref) => { 47 | const Comp = asChild ? Slot : "a"; 48 | 49 | return ( 50 | 55 | ); 56 | }); 57 | BreadcrumbLink.displayName = "BreadcrumbLink"; 58 | 59 | const BreadcrumbPage = React.forwardRef< 60 | HTMLSpanElement, 61 | React.ComponentPropsWithoutRef<"span"> 62 | >(({ className, ...props }, ref) => ( 63 | 71 | )); 72 | BreadcrumbPage.displayName = "BreadcrumbPage"; 73 | 74 | const BreadcrumbSeparator = ({ 75 | children, 76 | className, 77 | ...props 78 | }: React.ComponentProps<"li">) => ( 79 | svg]:w-3.5 [&>svg]:h-3.5", className)} 83 | {...props} 84 | > 85 | {children ?? } 86 | 87 | ); 88 | BreadcrumbSeparator.displayName = "BreadcrumbSeparator"; 89 | 90 | const BreadcrumbEllipsis = ({ 91 | className, 92 | ...props 93 | }: React.ComponentProps<"span">) => ( 94 | 100 | 101 | More 102 | 103 | ); 104 | BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; 105 | 106 | export { 107 | Breadcrumb, 108 | BreadcrumbList, 109 | BreadcrumbItem, 110 | BreadcrumbLink, 111 | BreadcrumbPage, 112 | BreadcrumbSeparator, 113 | BreadcrumbEllipsis, 114 | }; 115 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import { fontFamily } from "tailwindcss/defaultTheme"; 3 | 4 | const config: Config = { 5 | darkMode: ["class", "class"], 6 | content: [ 7 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 9 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 10 | ], 11 | theme: { 12 | extend: { 13 | fontFamily: { 14 | sans: ["Inter", ...fontFamily.sans], 15 | }, 16 | colors: { 17 | background: "hsl(var(--background))", 18 | foreground: "hsl(var(--foreground))", 19 | card: { 20 | DEFAULT: "hsl(var(--card))", 21 | foreground: "hsl(var(--card-foreground))", 22 | }, 23 | popover: { 24 | DEFAULT: "hsl(var(--popover))", 25 | foreground: "hsl(var(--popover-foreground))", 26 | }, 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | muted: { 36 | DEFAULT: "hsl(var(--muted))", 37 | foreground: "hsl(var(--muted-foreground))", 38 | }, 39 | accent: { 40 | DEFAULT: "hsl(var(--accent))", 41 | foreground: "hsl(var(--accent-foreground))", 42 | }, 43 | destructive: { 44 | DEFAULT: "hsl(var(--destructive))", 45 | foreground: "hsl(var(--destructive-foreground))", 46 | }, 47 | border: "hsl(var(--border))", 48 | input: "hsl(var(--input))", 49 | ring: "hsl(var(--ring))", 50 | chart: { 51 | "1": "hsl(var(--chart-1))", 52 | "2": "hsl(var(--chart-2))", 53 | "3": "hsl(var(--chart-3))", 54 | "4": "hsl(var(--chart-4))", 55 | "5": "hsl(var(--chart-5))", 56 | }, 57 | sidebar: { 58 | DEFAULT: "hsl(var(--sidebar-background))", 59 | foreground: "hsl(var(--sidebar-foreground))", 60 | primary: "hsl(var(--sidebar-primary))", 61 | "primary-foreground": "hsl(var(--sidebar-primary-foreground))", 62 | accent: "hsl(var(--sidebar-accent))", 63 | "accent-foreground": "hsl(var(--sidebar-accent-foreground))", 64 | border: "hsl(var(--sidebar-border))", 65 | ring: "hsl(var(--sidebar-ring))", 66 | }, 67 | }, 68 | borderRadius: { 69 | lg: "var(--radius)", 70 | md: "calc(var(--radius) - 2px)", 71 | sm: "calc(var(--radius) - 4px)", 72 | }, 73 | keyframes: { 74 | "accordion-down": { 75 | from: { 76 | height: "0", 77 | }, 78 | to: { 79 | height: "var(--radix-accordion-content-height)", 80 | }, 81 | }, 82 | "accordion-up": { 83 | from: { 84 | height: "var(--radix-accordion-content-height)", 85 | }, 86 | to: { 87 | height: "0", 88 | }, 89 | }, 90 | }, 91 | animation: { 92 | "accordion-down": "accordion-down 0.2s ease-out", 93 | "accordion-up": "accordion-up 0.2s ease-out", 94 | }, 95 | }, 96 | }, 97 | // eslint-disable-next-line @typescript-eslint/no-require-imports 98 | plugins: [require("tailwindcss-animate")], 99 | }; 100 | 101 | export default config; 102 | -------------------------------------------------------------------------------- /components/desktop/ItemModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | import { Input } from "@/components/ui/input"; 3 | import { Button } from "@/components/ui/button"; 4 | import { X } from "lucide-react"; 5 | import { useDesktopContext } from "@/context/desktop"; 6 | import { toast } from "sonner"; 7 | 8 | export const ItemModal: React.FC = () => { 9 | const { modalState, setModalState, handleItemOperation } = 10 | useDesktopContext(); 11 | const [name, setName] = useState(""); 12 | const [link, setLink] = useState(""); 13 | const inputRef = useRef(null); 14 | 15 | useEffect(() => { 16 | if (modalState.open) { 17 | setName(modalState.item?.name || ""); 18 | setLink(modalState.item?.link || ""); 19 | setTimeout(() => inputRef.current?.focus(), 0); 20 | } 21 | }, [modalState]); 22 | 23 | const handleSubmit = (e: React.FormEvent) => { 24 | e.preventDefault(); 25 | if (modalState.itemType === "file" && !/^https?:\/\//.test(link)) { 26 | toast.error("Please enter a valid URL."); 27 | return; 28 | } 29 | handleItemOperation(name, link); 30 | }; 31 | 32 | const handleKeyDown = (e: React.KeyboardEvent) => { 33 | if (e.key === "Escape") { 34 | setModalState({ 35 | open: false, 36 | type: null, 37 | itemType: null, 38 | locationId: null, 39 | item: null, 40 | }); 41 | } 42 | }; 43 | 44 | if (!modalState.open) return null; 45 | 46 | return ( 47 | 52 | 53 | 54 | 55 | 56 | {modalState.type === "new" 57 | ? modalState.itemType === "folder" 58 | ? "New Folder" 59 | : "New Bookmark" 60 | : modalState.type === "edit" 61 | ? "Edit Bookmark" 62 | : "Rename Folder"} 63 | 64 | 67 | setModalState({ 68 | open: false, 69 | type: null, 70 | itemType: null, 71 | locationId: null, 72 | item: null, 73 | }) 74 | } 75 | > 76 | 77 | 78 | 79 | 80 | setName(e.target.value)} 84 | placeholder={ 85 | modalState.itemType === "folder" 86 | ? "Folder name" 87 | : "Bookmark name" 88 | } 89 | required 90 | /> 91 | {modalState.itemType === "file" && ( 92 | setLink(e.target.value)} 95 | placeholder="https://example.com" 96 | required={modalState.type === "new"} 97 | /> 98 | )} 99 | 100 | {modalState.type === "new" ? "Create" : "Save"} 101 | 102 | 103 | 104 | 105 | 106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /components/ui/animated-grid-pattern.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | "use client"; 4 | 5 | import { useEffect, useId, useRef, useState } from "react"; 6 | import { motion } from "framer-motion"; 7 | 8 | import { cn } from "@/lib/utils"; 9 | 10 | interface GridPatternProps { 11 | width?: number; 12 | height?: number; 13 | x?: number; 14 | y?: number; 15 | strokeDasharray?: any; 16 | numSquares?: number; 17 | className?: string; 18 | maxOpacity?: number; 19 | duration?: number; 20 | repeatDelay?: number; 21 | } 22 | 23 | export function GridPattern({ 24 | width = 40, 25 | height = 40, 26 | x = -1, 27 | y = -1, 28 | strokeDasharray = 0, 29 | numSquares = 50, 30 | className, 31 | maxOpacity = 0.5, 32 | duration = 4, 33 | repeatDelay = 0.5, 34 | ...props 35 | }: GridPatternProps) { 36 | const id = useId(); 37 | const containerRef = useRef(null); 38 | const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); 39 | const [squares, setSquares] = useState(() => generateSquares(numSquares)); 40 | 41 | function getPos() { 42 | return [ 43 | Math.floor((Math.random() * dimensions.width) / width), 44 | Math.floor((Math.random() * dimensions.height) / height), 45 | ]; 46 | } 47 | 48 | // Adjust the generateSquares function to return objects with an id, x, and y 49 | function generateSquares(count: number) { 50 | return Array.from({ length: count }, (_, i) => ({ 51 | id: i, 52 | pos: getPos(), 53 | })); 54 | } 55 | 56 | // Function to update a single square's position 57 | const updateSquarePosition = (id: number) => { 58 | setSquares((currentSquares) => 59 | currentSquares.map((sq) => 60 | sq.id === id 61 | ? { 62 | ...sq, 63 | pos: getPos(), 64 | } 65 | : sq 66 | ) 67 | ); 68 | }; 69 | 70 | // Update squares to animate in 71 | useEffect(() => { 72 | if (dimensions.width && dimensions.height) { 73 | setSquares(generateSquares(numSquares)); 74 | } 75 | }, [dimensions, numSquares, generateSquares]); 76 | 77 | // Resize observer to update container dimensions 78 | useEffect(() => { 79 | const resizeObserver = new ResizeObserver((entries) => { 80 | for (const entry of entries) { 81 | setDimensions({ 82 | width: entry.contentRect.width, 83 | height: entry.contentRect.height, 84 | }); 85 | } 86 | }); 87 | 88 | if (containerRef.current) { 89 | resizeObserver.observe(containerRef.current); 90 | } 91 | 92 | return () => { 93 | if (containerRef.current) { 94 | resizeObserver.unobserve(containerRef.current); 95 | } 96 | }; 97 | }, [containerRef]); 98 | 99 | return ( 100 | 109 | 110 | 118 | 123 | 124 | 125 | 126 | 127 | {squares.map(({ pos: [x, y], id }, index) => ( 128 | updateSquarePosition(id)} 138 | key={`${x}-${y}-${index}`} 139 | width={width - 1} 140 | height={height - 1} 141 | x={x * width + 1} 142 | y={y * height + 1} 143 | fill="currentColor" 144 | strokeWidth="0" 145 | /> 146 | ))} 147 | 148 | 149 | ); 150 | } 151 | 152 | export default GridPattern; 153 | -------------------------------------------------------------------------------- /components/desktop/DesktopItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | import { Edit, Folder, File, Trash2, Copy, Scissors } from "lucide-react"; 3 | import { useDrag, useDrop } from "react-dnd"; 4 | import { 5 | ContextMenu, 6 | ContextMenuTrigger, 7 | ContextMenuContent, 8 | ContextMenuItem, 9 | } from "@/components/ui/context-menu"; 10 | import type { Item } from "@/types/desktop"; 11 | import { useDesktopContext } from "@/context/desktop"; 12 | 13 | interface DesktopItemProps { 14 | item: Item; 15 | } 16 | 17 | export const DesktopItem: React.FC = React.memo( 18 | ({ item }) => { 19 | const { 20 | pasteItem, 21 | openWindow, 22 | setModalState, 23 | deleteItem, 24 | handleCopy, 25 | handleCut, 26 | } = useDesktopContext(); 27 | const ref = useRef(null); 28 | 29 | const [{ isDragging }, drag] = useDrag({ 30 | type: "ITEM", 31 | item: () => { 32 | handleCut(item); 33 | return item; 34 | }, 35 | collect: (monitor) => ({ 36 | isDragging: !!monitor.isDragging(), 37 | }), 38 | end: (_, monitor) => { 39 | const location = monitor.getDropResult<{ id: string }>(); 40 | if (location) pasteItem(location.id); 41 | }, 42 | }); 43 | 44 | const [{ isOver, canDrop }, drop] = useDrop({ 45 | accept: "ITEM", 46 | canDrop: (draggedItem: Item) => 47 | item.type === "folder" && draggedItem.id !== item.id, 48 | drop: () => ({ id: item.id }), 49 | collect: (monitor) => ({ 50 | isOver: monitor.isOver({ shallow: true }), 51 | canDrop: monitor.canDrop(), 52 | }), 53 | }); 54 | 55 | useEffect(() => { 56 | if (item.type === "folder" && ref.current) { 57 | drop(ref.current); 58 | } 59 | }, [drop, item.type]); 60 | 61 | const handleClick = () => { 62 | if (item.type === "file" && item.link) { 63 | window.open(item.link, "_blank"); 64 | } else if (item.type === "folder") { 65 | openWindow(item); 66 | } 67 | }; 68 | 69 | return ( 70 | 71 | 72 | 79 | { 81 | drag(node as unknown as HTMLElement); 82 | }} 83 | className="p-2 flex items-center justify-center rounded-lg shadow-md border bg-neutral-800/30 backdrop-blur-xl border-neutral-800/60" 84 | > 85 | {item.type === "folder" ? ( 86 | 87 | ) : ( 88 | 89 | )} 90 | 91 | {item.name} 92 | 93 | 94 | 95 | handleCopy(item)}> 96 | 97 | Copy 98 | 99 | handleCut(item)}> 100 | 101 | Cut 102 | 103 | 105 | setModalState({ 106 | open: true, 107 | type: item.type === "file" ? "edit" : "rename", 108 | itemType: item.type, 109 | locationId: item.locationId, 110 | item, 111 | }) 112 | } 113 | > 114 | 115 | 116 | {item.type === "file" ? "Edit Bookmark" : "Rename Folder"} 117 | 118 | 119 | deleteItem(item.id)}> 120 | 121 | Delete {item.type === "folder" ? "Folder" : "Bookmark"} 122 | 123 | 124 | 125 | ); 126 | } 127 | ); 128 | 129 | DesktopItem.displayName = "DesktopItem"; 130 | -------------------------------------------------------------------------------- /components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SheetPrimitive from "@radix-ui/react-dialog"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | import { cn } from "@/lib/utils"; 7 | import { Cross2Icon } from "@radix-ui/react-icons"; 8 | 9 | const Sheet = SheetPrimitive.Root; 10 | 11 | const SheetTrigger = SheetPrimitive.Trigger; 12 | 13 | const SheetClose = SheetPrimitive.Close; 14 | 15 | const SheetPortal = SheetPrimitive.Portal; 16 | 17 | const SheetOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )); 30 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; 31 | 32 | const sheetVariants = cva( 33 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", 34 | { 35 | variants: { 36 | side: { 37 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 38 | bottom: 39 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 40 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 41 | right: 42 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 43 | }, 44 | }, 45 | defaultVariants: { 46 | side: "right", 47 | }, 48 | } 49 | ); 50 | 51 | interface SheetContentProps 52 | extends React.ComponentPropsWithoutRef, 53 | VariantProps {} 54 | 55 | const SheetContent = React.forwardRef< 56 | React.ElementRef, 57 | SheetContentProps 58 | >(({ side = "right", className, children, ...props }, ref) => ( 59 | 60 | 61 | 66 | 67 | 68 | Close 69 | 70 | {children} 71 | 72 | 73 | )); 74 | SheetContent.displayName = SheetPrimitive.Content.displayName; 75 | 76 | const SheetHeader = ({ 77 | className, 78 | ...props 79 | }: React.HTMLAttributes) => ( 80 | 87 | ); 88 | SheetHeader.displayName = "SheetHeader"; 89 | 90 | const SheetFooter = ({ 91 | className, 92 | ...props 93 | }: React.HTMLAttributes) => ( 94 | 101 | ); 102 | SheetFooter.displayName = "SheetFooter"; 103 | 104 | const SheetTitle = React.forwardRef< 105 | React.ElementRef, 106 | React.ComponentPropsWithoutRef 107 | >(({ className, ...props }, ref) => ( 108 | 113 | )); 114 | SheetTitle.displayName = SheetPrimitive.Title.displayName; 115 | 116 | const SheetDescription = React.forwardRef< 117 | React.ElementRef, 118 | React.ComponentPropsWithoutRef 119 | >(({ className, ...props }, ref) => ( 120 | 125 | )); 126 | SheetDescription.displayName = SheetPrimitive.Description.displayName; 127 | 128 | export { 129 | Sheet, 130 | SheetPortal, 131 | SheetOverlay, 132 | SheetTrigger, 133 | SheetClose, 134 | SheetContent, 135 | SheetHeader, 136 | SheetFooter, 137 | SheetTitle, 138 | SheetDescription, 139 | }; 140 | -------------------------------------------------------------------------------- /components/ui/context-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; 5 | import { 6 | CheckIcon, 7 | ChevronRightIcon, 8 | DotFilledIcon, 9 | } from "@radix-ui/react-icons"; 10 | 11 | import { cn } from "@/lib/utils"; 12 | 13 | const ContextMenu = ContextMenuPrimitive.Root; 14 | 15 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger; 16 | 17 | const ContextMenuGroup = ContextMenuPrimitive.Group; 18 | 19 | const ContextMenuPortal = ContextMenuPrimitive.Portal; 20 | 21 | const ContextMenuSub = ContextMenuPrimitive.Sub; 22 | 23 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; 24 | 25 | const ContextMenuSubTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef & { 28 | inset?: boolean; 29 | } 30 | >(({ className, inset, children, ...props }, ref) => ( 31 | 40 | {children} 41 | 42 | 43 | )); 44 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; 45 | 46 | const ContextMenuSubContent = React.forwardRef< 47 | React.ElementRef, 48 | React.ComponentPropsWithoutRef 49 | >(({ className, ...props }, ref) => ( 50 | 58 | )); 59 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; 60 | 61 | const ContextMenuContent = React.forwardRef< 62 | React.ElementRef, 63 | React.ComponentPropsWithoutRef 64 | >(({ className, ...props }, ref) => ( 65 | 66 | 74 | 75 | )); 76 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; 77 | 78 | const ContextMenuItem = React.forwardRef< 79 | React.ElementRef, 80 | React.ComponentPropsWithoutRef & { 81 | inset?: boolean; 82 | } 83 | >(({ className, inset, ...props }, ref) => ( 84 | 93 | )); 94 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; 95 | 96 | const ContextMenuCheckboxItem = React.forwardRef< 97 | React.ElementRef, 98 | React.ComponentPropsWithoutRef 99 | >(({ className, children, checked, ...props }, ref) => ( 100 | 109 | 110 | 111 | 112 | 113 | 114 | {children} 115 | 116 | )); 117 | ContextMenuCheckboxItem.displayName = 118 | ContextMenuPrimitive.CheckboxItem.displayName; 119 | 120 | const ContextMenuRadioItem = React.forwardRef< 121 | React.ElementRef, 122 | React.ComponentPropsWithoutRef 123 | >(({ className, children, ...props }, ref) => ( 124 | 132 | 133 | 134 | 135 | 136 | 137 | {children} 138 | 139 | )); 140 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; 141 | 142 | const ContextMenuLabel = React.forwardRef< 143 | React.ElementRef, 144 | React.ComponentPropsWithoutRef & { 145 | inset?: boolean; 146 | } 147 | >(({ className, inset, ...props }, ref) => ( 148 | 157 | )); 158 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; 159 | 160 | const ContextMenuSeparator = React.forwardRef< 161 | React.ElementRef, 162 | React.ComponentPropsWithoutRef 163 | >(({ className, ...props }, ref) => ( 164 | 169 | )); 170 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; 171 | 172 | const ContextMenuShortcut = ({ 173 | className, 174 | ...props 175 | }: React.HTMLAttributes) => { 176 | return ( 177 | 184 | ); 185 | }; 186 | ContextMenuShortcut.displayName = "ContextMenuShortcut"; 187 | 188 | export { 189 | ContextMenu, 190 | ContextMenuTrigger, 191 | ContextMenuContent, 192 | ContextMenuItem, 193 | ContextMenuCheckboxItem, 194 | ContextMenuRadioItem, 195 | ContextMenuLabel, 196 | ContextMenuSeparator, 197 | ContextMenuShortcut, 198 | ContextMenuGroup, 199 | ContextMenuPortal, 200 | ContextMenuSub, 201 | ContextMenuSubContent, 202 | ContextMenuSubTrigger, 203 | ContextMenuRadioGroup, 204 | }; 205 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 5 | import { cn } from "@/lib/utils"; 6 | import { 7 | CheckIcon, 8 | ChevronRightIcon, 9 | DotFilledIcon, 10 | } from "@radix-ui/react-icons"; 11 | 12 | const DropdownMenu = DropdownMenuPrimitive.Root; 13 | 14 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 15 | 16 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 17 | 18 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 19 | 20 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 21 | 22 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 23 | 24 | const DropdownMenuSubTrigger = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef & { 27 | inset?: boolean; 28 | } 29 | >(({ className, inset, children, ...props }, ref) => ( 30 | 39 | {children} 40 | 41 | 42 | )); 43 | DropdownMenuSubTrigger.displayName = 44 | DropdownMenuPrimitive.SubTrigger.displayName; 45 | 46 | const DropdownMenuSubContent = React.forwardRef< 47 | React.ElementRef, 48 | React.ComponentPropsWithoutRef 49 | >(({ className, ...props }, ref) => ( 50 | 58 | )); 59 | DropdownMenuSubContent.displayName = 60 | DropdownMenuPrimitive.SubContent.displayName; 61 | 62 | const DropdownMenuContent = React.forwardRef< 63 | React.ElementRef, 64 | React.ComponentPropsWithoutRef 65 | >(({ className, sideOffset = 4, ...props }, ref) => ( 66 | 67 | 77 | 78 | )); 79 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 80 | 81 | const DropdownMenuItem = React.forwardRef< 82 | React.ElementRef, 83 | React.ComponentPropsWithoutRef & { 84 | inset?: boolean; 85 | } 86 | >(({ className, inset, ...props }, ref) => ( 87 | svg]:size-4 [&>svg]:shrink-0", 91 | inset && "pl-8", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )); 97 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 98 | 99 | const DropdownMenuCheckboxItem = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, children, checked, ...props }, ref) => ( 103 | 112 | 113 | 114 | 115 | 116 | 117 | {children} 118 | 119 | )); 120 | DropdownMenuCheckboxItem.displayName = 121 | DropdownMenuPrimitive.CheckboxItem.displayName; 122 | 123 | const DropdownMenuRadioItem = React.forwardRef< 124 | React.ElementRef, 125 | React.ComponentPropsWithoutRef 126 | >(({ className, children, ...props }, ref) => ( 127 | 135 | 136 | 137 | 138 | 139 | 140 | {children} 141 | 142 | )); 143 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 144 | 145 | const DropdownMenuLabel = React.forwardRef< 146 | React.ElementRef, 147 | React.ComponentPropsWithoutRef & { 148 | inset?: boolean; 149 | } 150 | >(({ className, inset, ...props }, ref) => ( 151 | 160 | )); 161 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 162 | 163 | const DropdownMenuSeparator = React.forwardRef< 164 | React.ElementRef, 165 | React.ComponentPropsWithoutRef 166 | >(({ className, ...props }, ref) => ( 167 | 172 | )); 173 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 174 | 175 | const DropdownMenuShortcut = ({ 176 | className, 177 | ...props 178 | }: React.HTMLAttributes) => { 179 | return ( 180 | 184 | ); 185 | }; 186 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 187 | 188 | export { 189 | DropdownMenu, 190 | DropdownMenuTrigger, 191 | DropdownMenuContent, 192 | DropdownMenuItem, 193 | DropdownMenuCheckboxItem, 194 | DropdownMenuRadioItem, 195 | DropdownMenuLabel, 196 | DropdownMenuSeparator, 197 | DropdownMenuShortcut, 198 | DropdownMenuGroup, 199 | DropdownMenuPortal, 200 | DropdownMenuSub, 201 | DropdownMenuSubContent, 202 | DropdownMenuSubTrigger, 203 | DropdownMenuRadioGroup, 204 | }; 205 | -------------------------------------------------------------------------------- /hooks/useDesktop.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useRef, useEffect } from "react"; 2 | import { toast } from "sonner"; 3 | import type { 4 | Item, 5 | WindowItem, 6 | ModalState, 7 | ClipboardItem, 8 | } from "@/types/desktop"; 9 | import { initialItems } from "@/config/desktop"; 10 | 11 | export type DesktopContextValue = ReturnType; 12 | export const useDesktop = () => { 13 | const [items, setItems] = useState(initialItems); 14 | const [windows, setWindows] = useState([]); 15 | const [modalState, setModalState] = useState({ 16 | open: false, 17 | type: null, 18 | itemType: null, 19 | locationId: null, 20 | item: null, 21 | }); 22 | const [clipboard, setClipboard] = useState(null); 23 | const desktopRef = useRef(null); 24 | 25 | useEffect(() => { 26 | console.log({ 27 | operation: "clipboard changed", 28 | clipboard: JSON.stringify(clipboard, null, 2), 29 | }); 30 | }, [clipboard]); 31 | 32 | // Update windows state whenever items change 33 | useEffect(() => { 34 | setWindows((prevWindows) => 35 | prevWindows.map((window) => { 36 | const updatedItem = findItemById(items, window.itemId); 37 | return updatedItem ? { ...window, item: updatedItem } : window; 38 | }) 39 | ); 40 | }, [items]); 41 | 42 | const handleCreateFile = (locationId?: string | null) => { 43 | setModalState({ 44 | open: true, 45 | type: "new", 46 | itemType: "file", 47 | locationId: locationId || null, 48 | item: null, 49 | }); 50 | }; 51 | 52 | const handleCreateFolder = (locationId?: string | null) => { 53 | setModalState({ 54 | open: true, 55 | type: "new", 56 | itemType: "folder", 57 | locationId: locationId || null, 58 | item: null, 59 | }); 60 | }; 61 | 62 | const getItemPath = useCallback((items: Item[], itemId: string): string => { 63 | const findPath = (currentItems: Item[]): string | null => { 64 | for (const item of currentItems) { 65 | if (item.id === itemId) { 66 | return item.path; 67 | } 68 | if (item.content) { 69 | const path = findPath(item.content); 70 | if (path) { 71 | return path; 72 | } 73 | } 74 | } 75 | return null; 76 | }; 77 | 78 | return findPath(items) || "/desktop"; 79 | }, []); 80 | 81 | const findItemById = useCallback((items: Item[], id: string): Item | null => { 82 | for (const item of items) { 83 | if (item.id === id) return item; 84 | if (item.type === "folder" && item.content) { 85 | const found = findItemById(item.content, id); 86 | if (found) return found; 87 | } 88 | } 89 | return null; 90 | }, []); 91 | 92 | const findItemAndRemove = useCallback( 93 | (items: Item[], id?: string | null): [Item | null, Item[]] => { 94 | let removedItem: Item | null = null; 95 | 96 | const newItems = items.reduce((acc, item) => { 97 | if (item.id === id) { 98 | removedItem = { ...item }; 99 | return acc; 100 | } 101 | if (item.content) { 102 | const [found, updatedContent] = findItemAndRemove(item.content, id); 103 | if (found) { 104 | acc.push({ ...item, content: updatedContent }); 105 | removedItem = found; 106 | return acc; 107 | } 108 | } 109 | acc.push(item); 110 | return acc; 111 | }, []); 112 | 113 | return [removedItem, newItems]; 114 | }, 115 | [] 116 | ); 117 | 118 | const insertItem = useCallback( 119 | (items: Item[], item: Item, locationId?: string | null): Item[] => { 120 | if (locationId === null) { 121 | const newPath = `/desktop/${item.name}`; 122 | return [...items, { ...item, locationId: null, path: newPath }]; 123 | } 124 | 125 | return items.map((i) => { 126 | if (i.id === locationId) { 127 | const newPath = `${i.path}/${item.name}`; 128 | return { 129 | ...i, 130 | content: i.content 131 | ? [ 132 | ...i.content, 133 | { ...item, locationId: locationId, path: newPath }, 134 | ] 135 | : [{ ...item, locationId: locationId, path: newPath }], 136 | }; 137 | } 138 | if (i.content) { 139 | return { ...i, content: insertItem(i.content, item, locationId) }; 140 | } 141 | return i; 142 | }); 143 | }, 144 | [] 145 | ); 146 | 147 | const updateItemPath = useCallback((item: Item, parentPath: string): Item => { 148 | const newPath = `${parentPath}/${item.name}`; 149 | return { 150 | ...item, 151 | path: newPath, 152 | content: item.content?.map((child) => updateItemPath(child, newPath)), 153 | }; 154 | }, []); 155 | 156 | const pasteItem = useCallback( 157 | (locationId?: string | null) => { 158 | console.log({ operation: "paste", clipboard, locationId }); 159 | 160 | if (clipboard?.item.id === locationId) { 161 | toast( 162 | "Cannot move item to the same location. Please select a different location." 163 | ); 164 | return; // Prevent dragging onto itself 165 | } 166 | 167 | setItems((prevItems) => { 168 | const [draggedItem, newItems] = findItemAndRemove( 169 | prevItems, 170 | clipboard?.item.id 171 | ); 172 | if (!draggedItem) return prevItems; 173 | 174 | const updatedItems = insertItem(newItems, draggedItem, locationId); 175 | 176 | const targetPath = locationId 177 | ? getItemPath(updatedItems, locationId) 178 | : "/desktop"; 179 | 180 | toast.success( 181 | `Transferring ${draggedItem.name} ${draggedItem.type} from ${clipboard?.item.path} to ${targetPath}` 182 | ); 183 | 184 | clearClipboard(); 185 | 186 | return updatedItems; 187 | }); 188 | }, 189 | [clipboard, findItemAndRemove, insertItem, getItemPath] 190 | ); 191 | 192 | const handleCut = useCallback((item: Item) => { 193 | setClipboard({ 194 | item, 195 | operation: "cut", 196 | }); 197 | toast.success(`Cut ${item.name}`); 198 | }, []); 199 | 200 | const handleCopy = useCallback((item: Item) => { 201 | setClipboard({ 202 | item, 203 | operation: "copy", 204 | }); 205 | toast.success(`Copied ${item.name}`); 206 | }, []); 207 | 208 | const handleItemOperation = useCallback( 209 | (name: string, link?: string) => { 210 | if (modalState.type === "new") { 211 | const parentPath = modalState.locationId 212 | ? getItemPath(items, modalState.locationId) 213 | : "/desktop"; 214 | const newItem: Item = { 215 | id: Date.now().toString(), 216 | name, 217 | type: modalState.itemType!, 218 | locationId: modalState.locationId, 219 | path: `${parentPath}/${name}`, 220 | ...(modalState.itemType === "file" ? { link } : { content: [] }), 221 | }; 222 | 223 | setItems((prevItems) => { 224 | const updateItemsRecursively = (items: Item[]): Item[] => { 225 | return items.map((item) => { 226 | if (item.id === modalState.locationId) { 227 | return { 228 | ...item, 229 | content: [...(item.content || []), newItem], 230 | }; 231 | } 232 | if (item.content) { 233 | return { 234 | ...item, 235 | content: updateItemsRecursively(item.content), 236 | }; 237 | } 238 | return item; 239 | }); 240 | }; 241 | 242 | if (modalState.locationId === null) { 243 | return [...prevItems, newItem]; 244 | } else { 245 | return updateItemsRecursively(prevItems); 246 | } 247 | }); 248 | } else if (modalState.type === "edit" || modalState.type === "rename") { 249 | if (!modalState.item) { 250 | toast.error("No item selected for editing."); 251 | return; 252 | } 253 | 254 | setItems((prevItems) => { 255 | const updateItemRecursively = (items: Item[]): Item[] => { 256 | return items.map((item) => { 257 | if (item.id === modalState.item!.id) { 258 | const updatedItem = { ...item, name }; 259 | if (item.locationId) { 260 | const parentPath = getItemPath(prevItems, item.locationId); 261 | updatedItem.path = `${parentPath}/${name}`; 262 | } else { 263 | updatedItem.path = `/desktop/${name}`; 264 | } 265 | if (item.type === "file" && link) { 266 | updatedItem.link = link; 267 | } 268 | return updatedItem; 269 | } 270 | if (item.content) { 271 | return { 272 | ...item, 273 | content: updateItemRecursively(item.content), 274 | }; 275 | } 276 | return item; 277 | }); 278 | }; 279 | 280 | return updateItemRecursively(prevItems); 281 | }); 282 | } 283 | 284 | setModalState({ 285 | open: false, 286 | type: null, 287 | itemType: null, 288 | locationId: null, 289 | item: null, 290 | }); 291 | }, 292 | [modalState, items, getItemPath] 293 | ); 294 | 295 | const openWindow = useCallback((item: Item) => { 296 | setWindows((prev) => [ 297 | ...prev, 298 | { 299 | id: Date.now().toString(), 300 | itemId: item.id, 301 | position: { x: 50 + prev.length * 20, y: 50 + prev.length * 20 }, 302 | size: { width: 400, height: 300 }, 303 | isMinimized: false, 304 | item, // Include the item directly in the window object 305 | }, 306 | ]); 307 | }, []); 308 | 309 | const closeWindow = useCallback((windowId: string) => { 310 | setWindows((prev) => prev.filter((w) => w.id !== windowId)); 311 | }, []); 312 | 313 | const minimizeWindow = useCallback((windowId: string) => { 314 | setWindows((prev) => 315 | prev.map((w) => 316 | w.id === windowId ? { ...w, isMinimized: !w.isMinimized } : w 317 | ) 318 | ); 319 | }, []); 320 | 321 | const moveWindow = useCallback( 322 | (windowId: string, position: { x: number; y: number }) => { 323 | setWindows((prev) => 324 | prev.map((w) => (w.id === windowId ? { ...w, position } : w)) 325 | ); 326 | }, 327 | [] 328 | ); 329 | 330 | const deleteItem = useCallback((itemId: string) => { 331 | setItems((prevItems) => { 332 | const deleteItemRecursively = (items: Item[]): Item[] => { 333 | return items 334 | .filter((item) => item.id !== itemId) 335 | .map((item) => ({ 336 | ...item, 337 | content: item.content 338 | ? deleteItemRecursively(item.content) 339 | : undefined, 340 | })); 341 | }; 342 | 343 | return deleteItemRecursively(prevItems); 344 | }); 345 | 346 | setWindows((prevWindows) => prevWindows.filter((w) => w.itemId !== itemId)); 347 | 348 | toast.success("Item deleted successfully."); 349 | }, []); 350 | 351 | const clearClipboard = useCallback(() => { 352 | setClipboard(null); 353 | }, []); 354 | 355 | const handleEmptySpaceRightClick = useCallback((e: React.MouseEvent) => { 356 | e.preventDefault(); 357 | }, []); 358 | 359 | return { 360 | items, 361 | windows, 362 | clipboard, 363 | modalState, 364 | desktopRef, 365 | handleCreateFile, 366 | handleCreateFolder, 367 | pasteItem, 368 | openWindow, 369 | closeWindow, 370 | minimizeWindow, 371 | moveWindow, 372 | setModalState, 373 | handleItemOperation, 374 | deleteItem, 375 | handleCopy, 376 | handleCut, 377 | clearClipboard, 378 | handleEmptySpaceRightClick, 379 | }; 380 | }; 381 | -------------------------------------------------------------------------------- /components/ui/sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Slot } from "@radix-ui/react-slot" 5 | import { VariantProps, cva } from "class-variance-authority" 6 | import { useIsMobile } from "@/hooks/use-mobile" 7 | import { cn } from "@/lib/utils" 8 | import { Button } from "@/components/ui/button" 9 | import { Input } from "@/components/ui/input" 10 | import { Separator } from "@/components/ui/separator" 11 | import { Sheet, SheetContent } from "@/components/ui/sheet" 12 | import { Skeleton } from "@/components/ui/skeleton" 13 | import { 14 | Tooltip, 15 | TooltipContent, 16 | TooltipProvider, 17 | TooltipTrigger, 18 | } from "@/components/ui/tooltip" 19 | import { ViewVerticalIcon } from "@radix-ui/react-icons" 20 | 21 | const SIDEBAR_COOKIE_NAME = "sidebar:state" 22 | const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 23 | const SIDEBAR_WIDTH = "16rem" 24 | const SIDEBAR_WIDTH_MOBILE = "18rem" 25 | const SIDEBAR_WIDTH_ICON = "3rem" 26 | const SIDEBAR_KEYBOARD_SHORTCUT = "b" 27 | 28 | type SidebarContext = { 29 | state: "expanded" | "collapsed" 30 | open: boolean 31 | setOpen: (open: boolean) => void 32 | openMobile: boolean 33 | setOpenMobile: (open: boolean) => void 34 | isMobile: boolean 35 | toggleSidebar: () => void 36 | } 37 | 38 | const SidebarContext = React.createContext(null) 39 | 40 | function useSidebar() { 41 | const context = React.useContext(SidebarContext) 42 | if (!context) { 43 | throw new Error("useSidebar must be used within a SidebarProvider.") 44 | } 45 | 46 | return context 47 | } 48 | 49 | const SidebarProvider = React.forwardRef< 50 | HTMLDivElement, 51 | React.ComponentProps<"div"> & { 52 | defaultOpen?: boolean 53 | open?: boolean 54 | onOpenChange?: (open: boolean) => void 55 | } 56 | >( 57 | ( 58 | { 59 | defaultOpen = true, 60 | open: openProp, 61 | onOpenChange: setOpenProp, 62 | className, 63 | style, 64 | children, 65 | ...props 66 | }, 67 | ref 68 | ) => { 69 | const isMobile = useIsMobile() 70 | const [openMobile, setOpenMobile] = React.useState(false) 71 | 72 | // This is the internal state of the sidebar. 73 | // We use openProp and setOpenProp for control from outside the component. 74 | const [_open, _setOpen] = React.useState(defaultOpen) 75 | const open = openProp ?? _open 76 | const setOpen = React.useCallback( 77 | (value: boolean | ((value: boolean) => boolean)) => { 78 | const openState = typeof value === "function" ? value(open) : value 79 | if (setOpenProp) { 80 | setOpenProp(openState) 81 | } else { 82 | _setOpen(openState) 83 | } 84 | 85 | // This sets the cookie to keep the sidebar state. 86 | document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` 87 | }, 88 | [setOpenProp, open] 89 | ) 90 | 91 | // Helper to toggle the sidebar. 92 | const toggleSidebar = React.useCallback(() => { 93 | return isMobile 94 | ? setOpenMobile((open) => !open) 95 | : setOpen((open) => !open) 96 | }, [isMobile, setOpen, setOpenMobile]) 97 | 98 | // Adds a keyboard shortcut to toggle the sidebar. 99 | React.useEffect(() => { 100 | const handleKeyDown = (event: KeyboardEvent) => { 101 | if ( 102 | event.key === SIDEBAR_KEYBOARD_SHORTCUT && 103 | (event.metaKey || event.ctrlKey) 104 | ) { 105 | event.preventDefault() 106 | toggleSidebar() 107 | } 108 | } 109 | 110 | window.addEventListener("keydown", handleKeyDown) 111 | return () => window.removeEventListener("keydown", handleKeyDown) 112 | }, [toggleSidebar]) 113 | 114 | // We add a state so that we can do data-state="expanded" or "collapsed". 115 | // This makes it easier to style the sidebar with Tailwind classes. 116 | const state = open ? "expanded" : "collapsed" 117 | 118 | const contextValue = React.useMemo( 119 | () => ({ 120 | state, 121 | open, 122 | setOpen, 123 | isMobile, 124 | openMobile, 125 | setOpenMobile, 126 | toggleSidebar, 127 | }), 128 | [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] 129 | ) 130 | 131 | return ( 132 | 133 | 134 | 149 | {children} 150 | 151 | 152 | 153 | ) 154 | } 155 | ) 156 | SidebarProvider.displayName = "SidebarProvider" 157 | 158 | const Sidebar = React.forwardRef< 159 | HTMLDivElement, 160 | React.ComponentProps<"div"> & { 161 | side?: "left" | "right" 162 | variant?: "sidebar" | "floating" | "inset" 163 | collapsible?: "offcanvas" | "icon" | "none" 164 | } 165 | >( 166 | ( 167 | { 168 | side = "left", 169 | variant = "sidebar", 170 | collapsible = "offcanvas", 171 | className, 172 | children, 173 | ...props 174 | }, 175 | ref 176 | ) => { 177 | const { isMobile, state, openMobile, setOpenMobile } = useSidebar() 178 | 179 | if (collapsible === "none") { 180 | return ( 181 | 189 | {children} 190 | 191 | ) 192 | } 193 | 194 | if (isMobile) { 195 | return ( 196 | 197 | 208 | {children} 209 | 210 | 211 | ) 212 | } 213 | 214 | return ( 215 | 223 | {/* This is what handles the sidebar gap on desktop */} 224 | 234 | 248 | 252 | {children} 253 | 254 | 255 | 256 | ) 257 | } 258 | ) 259 | Sidebar.displayName = "Sidebar" 260 | 261 | const SidebarTrigger = React.forwardRef< 262 | React.ElementRef, 263 | React.ComponentProps 264 | >(({ className, onClick, ...props }, ref) => { 265 | const { toggleSidebar } = useSidebar() 266 | 267 | return ( 268 | { 275 | onClick?.(event) 276 | toggleSidebar() 277 | }} 278 | {...props} 279 | > 280 | 281 | Toggle Sidebar 282 | 283 | ) 284 | }) 285 | SidebarTrigger.displayName = "SidebarTrigger" 286 | 287 | const SidebarRail = React.forwardRef< 288 | HTMLButtonElement, 289 | React.ComponentProps<"button"> 290 | >(({ className, ...props }, ref) => { 291 | const { toggleSidebar } = useSidebar() 292 | 293 | return ( 294 | 312 | ) 313 | }) 314 | SidebarRail.displayName = "SidebarRail" 315 | 316 | const SidebarInset = React.forwardRef< 317 | HTMLDivElement, 318 | React.ComponentProps<"main"> 319 | >(({ className, ...props }, ref) => { 320 | return ( 321 | 330 | ) 331 | }) 332 | SidebarInset.displayName = "SidebarInset" 333 | 334 | const SidebarInput = React.forwardRef< 335 | React.ElementRef, 336 | React.ComponentProps 337 | >(({ className, ...props }, ref) => { 338 | return ( 339 | 348 | ) 349 | }) 350 | SidebarInput.displayName = "SidebarInput" 351 | 352 | const SidebarHeader = React.forwardRef< 353 | HTMLDivElement, 354 | React.ComponentProps<"div"> 355 | >(({ className, ...props }, ref) => { 356 | return ( 357 | 363 | ) 364 | }) 365 | SidebarHeader.displayName = "SidebarHeader" 366 | 367 | const SidebarFooter = React.forwardRef< 368 | HTMLDivElement, 369 | React.ComponentProps<"div"> 370 | >(({ className, ...props }, ref) => { 371 | return ( 372 | 378 | ) 379 | }) 380 | SidebarFooter.displayName = "SidebarFooter" 381 | 382 | const SidebarSeparator = React.forwardRef< 383 | React.ElementRef, 384 | React.ComponentProps 385 | >(({ className, ...props }, ref) => { 386 | return ( 387 | 393 | ) 394 | }) 395 | SidebarSeparator.displayName = "SidebarSeparator" 396 | 397 | const SidebarContent = React.forwardRef< 398 | HTMLDivElement, 399 | React.ComponentProps<"div"> 400 | >(({ className, ...props }, ref) => { 401 | return ( 402 | 411 | ) 412 | }) 413 | SidebarContent.displayName = "SidebarContent" 414 | 415 | const SidebarGroup = React.forwardRef< 416 | HTMLDivElement, 417 | React.ComponentProps<"div"> 418 | >(({ className, ...props }, ref) => { 419 | return ( 420 | 426 | ) 427 | }) 428 | SidebarGroup.displayName = "SidebarGroup" 429 | 430 | const SidebarGroupLabel = React.forwardRef< 431 | HTMLDivElement, 432 | React.ComponentProps<"div"> & { asChild?: boolean } 433 | >(({ className, asChild = false, ...props }, ref) => { 434 | const Comp = asChild ? Slot : "div" 435 | 436 | return ( 437 | svg]:size-4 [&>svg]:shrink-0", 442 | "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", 443 | className 444 | )} 445 | {...props} 446 | /> 447 | ) 448 | }) 449 | SidebarGroupLabel.displayName = "SidebarGroupLabel" 450 | 451 | const SidebarGroupAction = React.forwardRef< 452 | HTMLButtonElement, 453 | React.ComponentProps<"button"> & { asChild?: boolean } 454 | >(({ className, asChild = false, ...props }, ref) => { 455 | const Comp = asChild ? Slot : "button" 456 | 457 | return ( 458 | svg]:size-4 [&>svg]:shrink-0", 463 | // Increases the hit area of the button on mobile. 464 | "after:absolute after:-inset-2 after:md:hidden", 465 | "group-data-[collapsible=icon]:hidden", 466 | className 467 | )} 468 | {...props} 469 | /> 470 | ) 471 | }) 472 | SidebarGroupAction.displayName = "SidebarGroupAction" 473 | 474 | const SidebarGroupContent = React.forwardRef< 475 | HTMLDivElement, 476 | React.ComponentProps<"div"> 477 | >(({ className, ...props }, ref) => ( 478 | 484 | )) 485 | SidebarGroupContent.displayName = "SidebarGroupContent" 486 | 487 | const SidebarMenu = React.forwardRef< 488 | HTMLUListElement, 489 | React.ComponentProps<"ul"> 490 | >(({ className, ...props }, ref) => ( 491 | 497 | )) 498 | SidebarMenu.displayName = "SidebarMenu" 499 | 500 | const SidebarMenuItem = React.forwardRef< 501 | HTMLLIElement, 502 | React.ComponentProps<"li"> 503 | >(({ className, ...props }, ref) => ( 504 | 510 | )) 511 | SidebarMenuItem.displayName = "SidebarMenuItem" 512 | 513 | const sidebarMenuButtonVariants = cva( 514 | "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", 515 | { 516 | variants: { 517 | variant: { 518 | default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", 519 | outline: 520 | "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", 521 | }, 522 | size: { 523 | default: "h-8 text-sm", 524 | sm: "h-7 text-xs", 525 | lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", 526 | }, 527 | }, 528 | defaultVariants: { 529 | variant: "default", 530 | size: "default", 531 | }, 532 | } 533 | ) 534 | 535 | const SidebarMenuButton = React.forwardRef< 536 | HTMLButtonElement, 537 | React.ComponentProps<"button"> & { 538 | asChild?: boolean 539 | isActive?: boolean 540 | tooltip?: string | React.ComponentProps 541 | } & VariantProps 542 | >( 543 | ( 544 | { 545 | asChild = false, 546 | isActive = false, 547 | variant = "default", 548 | size = "default", 549 | tooltip, 550 | className, 551 | ...props 552 | }, 553 | ref 554 | ) => { 555 | const Comp = asChild ? Slot : "button" 556 | const { isMobile, state } = useSidebar() 557 | 558 | const button = ( 559 | 567 | ) 568 | 569 | if (!tooltip) { 570 | return button 571 | } 572 | 573 | if (typeof tooltip === "string") { 574 | tooltip = { 575 | children: tooltip, 576 | } 577 | } 578 | 579 | return ( 580 | 581 | {button} 582 | 588 | 589 | ) 590 | } 591 | ) 592 | SidebarMenuButton.displayName = "SidebarMenuButton" 593 | 594 | const SidebarMenuAction = React.forwardRef< 595 | HTMLButtonElement, 596 | React.ComponentProps<"button"> & { 597 | asChild?: boolean 598 | showOnHover?: boolean 599 | } 600 | >(({ className, asChild = false, showOnHover = false, ...props }, ref) => { 601 | const Comp = asChild ? Slot : "button" 602 | 603 | return ( 604 | svg]:size-4 [&>svg]:shrink-0", 609 | // Increases the hit area of the button on mobile. 610 | "after:absolute after:-inset-2 after:md:hidden", 611 | "peer-data-[size=sm]/menu-button:top-1", 612 | "peer-data-[size=default]/menu-button:top-1.5", 613 | "peer-data-[size=lg]/menu-button:top-2.5", 614 | "group-data-[collapsible=icon]:hidden", 615 | showOnHover && 616 | "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", 617 | className 618 | )} 619 | {...props} 620 | /> 621 | ) 622 | }) 623 | SidebarMenuAction.displayName = "SidebarMenuAction" 624 | 625 | const SidebarMenuBadge = React.forwardRef< 626 | HTMLDivElement, 627 | React.ComponentProps<"div"> 628 | >(({ className, ...props }, ref) => ( 629 | 643 | )) 644 | SidebarMenuBadge.displayName = "SidebarMenuBadge" 645 | 646 | const SidebarMenuSkeleton = React.forwardRef< 647 | HTMLDivElement, 648 | React.ComponentProps<"div"> & { 649 | showIcon?: boolean 650 | } 651 | >(({ className, showIcon = false, ...props }, ref) => { 652 | // Random width between 50 to 90%. 653 | const width = React.useMemo(() => { 654 | return `${Math.floor(Math.random() * 40) + 50}%` 655 | }, []) 656 | 657 | return ( 658 | 664 | {showIcon && ( 665 | 669 | )} 670 | 679 | 680 | ) 681 | }) 682 | SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton" 683 | 684 | const SidebarMenuSub = React.forwardRef< 685 | HTMLUListElement, 686 | React.ComponentProps<"ul"> 687 | >(({ className, ...props }, ref) => ( 688 | 698 | )) 699 | SidebarMenuSub.displayName = "SidebarMenuSub" 700 | 701 | const SidebarMenuSubItem = React.forwardRef< 702 | HTMLLIElement, 703 | React.ComponentProps<"li"> 704 | >(({ ...props }, ref) => ) 705 | SidebarMenuSubItem.displayName = "SidebarMenuSubItem" 706 | 707 | const SidebarMenuSubButton = React.forwardRef< 708 | HTMLAnchorElement, 709 | React.ComponentProps<"a"> & { 710 | asChild?: boolean 711 | size?: "sm" | "md" 712 | isActive?: boolean 713 | } 714 | >(({ asChild = false, size = "md", isActive, className, ...props }, ref) => { 715 | const Comp = asChild ? Slot : "a" 716 | 717 | return ( 718 | span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", 725 | "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", 726 | size === "sm" && "text-xs", 727 | size === "md" && "text-sm", 728 | "group-data-[collapsible=icon]:hidden", 729 | className 730 | )} 731 | {...props} 732 | /> 733 | ) 734 | }) 735 | SidebarMenuSubButton.displayName = "SidebarMenuSubButton" 736 | 737 | export { 738 | Sidebar, 739 | SidebarContent, 740 | SidebarFooter, 741 | SidebarGroup, 742 | SidebarGroupAction, 743 | SidebarGroupContent, 744 | SidebarGroupLabel, 745 | SidebarHeader, 746 | SidebarInput, 747 | SidebarInset, 748 | SidebarMenu, 749 | SidebarMenuAction, 750 | SidebarMenuBadge, 751 | SidebarMenuButton, 752 | SidebarMenuItem, 753 | SidebarMenuSkeleton, 754 | SidebarMenuSub, 755 | SidebarMenuSubButton, 756 | SidebarMenuSubItem, 757 | SidebarProvider, 758 | SidebarRail, 759 | SidebarSeparator, 760 | SidebarTrigger, 761 | useSidebar, 762 | } 763 | --------------------------------------------------------------------------------