diff --git a/src/components/features/snippet-display/SnippetCard.tsx b/src/components/features/snippet-display/SnippetCard.tsx
index b9d5382..a065df4 100644
--- a/src/components/features/snippet-display/SnippetCard.tsx
+++ b/src/components/features/snippet-display/SnippetCard.tsx
@@ -1,23 +1,12 @@
import { useState, useMemo, useEffect } from 'react'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
-import { Checkbox } from '@/components/ui/checkbox'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu'
-import { Copy, Pencil, Trash, Eye, DotsThree, FolderOpen } from '@phosphor-icons/react'
import { Snippet, Namespace } from '@/lib/types'
-import { strings, appConfig, LANGUAGE_COLORS } from '@/lib/config'
+import { strings, appConfig } from '@/lib/config'
import { getAllNamespaces, moveSnippetToNamespace } from '@/lib/db'
import { toast } from 'sonner'
+import { SnippetCardHeader } from './SnippetCardHeader'
+import { SnippetCodePreview } from './SnippetCodePreview'
+import { SnippetCardActions } from './SnippetCardActions'
interface SnippetCardProps {
snippet: Snippet
@@ -157,127 +146,30 @@ export function SnippetCard({
onClick={handleView}
>
-
-
- {selectionMode && (
-
e.stopPropagation()}
- className="mt-1"
- />
- )}
-
-
- {snippet.title}
-
- {snippetData.description && (
-
- {snippetData.description}
-
- )}
-
-
-
- {snippet.language}
-
-
+
-
-
- {snippetData.displayCode}
-
- {snippetData.isTruncated && (
-
- {strings.snippetCard.viewFullCode}
-
- )}
-
+
{!selectionMode && (
-
-
-
-
- {strings.snippetCard.viewButton}
-
-
-
-
-
- {isCopied ? strings.snippetCard.copiedButton : strings.snippetCard.copyButton}
-
-
-
-
-
-
-
- e.stopPropagation()}
- aria-label="More options"
- >
-
-
-
- e.stopPropagation()}>
-
-
-
- Move to...
-
-
- {availableNamespaces.length === 0 ? (
-
- No other namespaces
-
- ) : (
- availableNamespaces.map((namespace) => (
- handleMoveToNamespace(namespace.id)}
- >
- {namespace.name}
- {namespace.isDefault && (
- (Default)
- )}
-
- ))
- )}
-
-
-
-
-
- Delete
-
-
-
-
-
+
)}
diff --git a/src/components/features/snippet-display/SnippetCardActions.tsx b/src/components/features/snippet-display/SnippetCardActions.tsx
new file mode 100644
index 0000000..95becba
--- /dev/null
+++ b/src/components/features/snippet-display/SnippetCardActions.tsx
@@ -0,0 +1,120 @@
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Copy, Pencil, Trash, Eye, DotsThree, FolderOpen } from '@phosphor-icons/react'
+import { Namespace } from '@/lib/types'
+import { strings } from '@/lib/config'
+
+interface SnippetCardActionsProps {
+ isCopied: boolean
+ isMoving: boolean
+ availableNamespaces: Namespace[]
+ onView: (e: React.MouseEvent) => void
+ onCopy: (e: React.MouseEvent) => void
+ onEdit: (e: React.MouseEvent) => void
+ onDelete: (e: React.MouseEvent) => void
+ onMoveToNamespace: (namespaceId: string) => void
+}
+
+export function SnippetCardActions({
+ isCopied,
+ isMoving,
+ availableNamespaces,
+ onView,
+ onCopy,
+ onEdit,
+ onDelete,
+ onMoveToNamespace,
+}: SnippetCardActionsProps) {
+ return (
+
+
+
+
+ {strings.snippetCard.viewButton}
+
+
+
+
+
+ {isCopied ? strings.snippetCard.copiedButton : strings.snippetCard.copyButton}
+
+
+
+
+
+
+
+ e.stopPropagation()}
+ aria-label="More options"
+ >
+
+
+
+ e.stopPropagation()}>
+
+
+
+ Move to...
+
+
+ {availableNamespaces.length === 0 ? (
+
+ No other namespaces
+
+ ) : (
+ availableNamespaces.map((namespace) => (
+ onMoveToNamespace(namespace.id)}
+ >
+ {namespace.name}
+ {namespace.isDefault && (
+ (Default)
+ )}
+
+ ))
+ )}
+
+
+
+
+
+ Delete
+
+
+
+
+
+ )
+}
diff --git a/src/components/features/snippet-display/SnippetCardHeader.tsx b/src/components/features/snippet-display/SnippetCardHeader.tsx
new file mode 100644
index 0000000..d36be64
--- /dev/null
+++ b/src/components/features/snippet-display/SnippetCardHeader.tsx
@@ -0,0 +1,50 @@
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Snippet } from '@/lib/types'
+import { LANGUAGE_COLORS } from '@/lib/config'
+
+interface SnippetCardHeaderProps {
+ snippet: Snippet
+ description: string
+ selectionMode: boolean
+ isSelected: boolean
+ onToggleSelect: () => void
+}
+
+export function SnippetCardHeader({
+ snippet,
+ description,
+ selectionMode,
+ isSelected,
+ onToggleSelect
+}: SnippetCardHeaderProps) {
+ return (
+
+
+ {selectionMode && (
+
e.stopPropagation()}
+ className="mt-1"
+ />
+ )}
+
+
+ {snippet.title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ {snippet.language}
+
+
+ )
+}
diff --git a/src/components/features/snippet-display/SnippetCodePreview.tsx b/src/components/features/snippet-display/SnippetCodePreview.tsx
new file mode 100644
index 0000000..ed7818d
--- /dev/null
+++ b/src/components/features/snippet-display/SnippetCodePreview.tsx
@@ -0,0 +1,21 @@
+import { strings } from '@/lib/config'
+
+interface SnippetCodePreviewProps {
+ displayCode: string
+ isTruncated: boolean
+}
+
+export function SnippetCodePreview({ displayCode, isTruncated }: SnippetCodePreviewProps) {
+ return (
+
+
+ {displayCode}
+
+ {isTruncated && (
+
+ {strings.snippetCard.viewFullCode}
+
+ )}
+
+ )
+}
diff --git a/src/components/features/snippet-viewer/SnippetViewer.tsx b/src/components/features/snippet-viewer/SnippetViewer.tsx
index 70d03b2..3815fe9 100644
--- a/src/components/features/snippet-viewer/SnippetViewer.tsx
+++ b/src/components/features/snippet-viewer/SnippetViewer.tsx
@@ -2,18 +2,12 @@ import {
Dialog,
DialogContent,
DialogHeader,
- DialogTitle,
} from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Badge } from '@/components/ui/badge'
-import { Copy, Pencil, Check, SplitVertical } from '@phosphor-icons/react'
import { Snippet } from '@/lib/types'
-import { MonacoEditor } from '@/components/features/snippet-editor/MonacoEditor'
-import { ReactPreview } from '@/components/features/snippet-editor/ReactPreview'
-import { PythonOutput } from '@/components/features/python-runner/PythonOutput'
-import { cn } from '@/lib/utils'
import { useState } from 'react'
-import { strings, appConfig, LANGUAGE_COLORS } from '@/lib/config'
+import { appConfig } from '@/lib/config'
+import { SnippetViewerHeader } from './SnippetViewerHeader'
+import { SnippetViewerContent } from './SnippetViewerContent'
interface SnippetViewerProps {
snippet: Snippet | null
@@ -47,110 +41,24 @@ export function SnippetViewer({ snippet, open, onOpenChange, onEdit, onCopy }: S
-
-
-
-
- {snippet.title}
-
-
- {snippet.language}
-
-
- {snippet.description && (
-
- {snippet.description}
-
- )}
-
- {strings.snippetViewer.lastUpdated}: {new Date(snippet.updatedAt).toLocaleString()}
-
-
-
- {canPreview && (
-
setShowPreview(!showPreview)}
- className="gap-2"
- >
-
- {showPreview ? strings.snippetViewer.buttons.hidePreview : strings.snippetViewer.buttons.showPreview}
-
- )}
-
- {isCopied ? (
- <>
-
- {strings.snippetViewer.buttons.copied}
- >
- ) : (
- <>
-
- {strings.snippetViewer.buttons.copy}
- >
- )}
-
-
-
- {strings.snippetViewer.buttons.edit}
-
-
-
+ setShowPreview(!showPreview)}
+ />
- {canPreview && showPreview ? (
- <>
-
- {}}
- language={snippet.language}
- height="100%"
- readOnly={true}
- />
-
-
- {isPython ? (
-
- ) : (
-
- )}
-
- >
- ) : (
-
- {}}
- language={snippet.language}
- height="100%"
- readOnly={true}
- />
-
- )}
+
diff --git a/src/components/features/snippet-viewer/SnippetViewerContent.tsx b/src/components/features/snippet-viewer/SnippetViewerContent.tsx
new file mode 100644
index 0000000..5524219
--- /dev/null
+++ b/src/components/features/snippet-viewer/SnippetViewerContent.tsx
@@ -0,0 +1,58 @@
+import { Snippet } from '@/lib/types'
+import { MonacoEditor } from '@/components/features/snippet-editor/MonacoEditor'
+import { ReactPreview } from '@/components/features/snippet-editor/ReactPreview'
+import { PythonOutput } from '@/components/features/python-runner/PythonOutput'
+
+interface SnippetViewerContentProps {
+ snippet: Snippet
+ canPreview: boolean
+ showPreview: boolean
+ isPython: boolean
+}
+
+export function SnippetViewerContent({
+ snippet,
+ canPreview,
+ showPreview,
+ isPython,
+}: SnippetViewerContentProps) {
+ if (canPreview && showPreview) {
+ return (
+ <>
+
+ {}}
+ language={snippet.language}
+ height="100%"
+ readOnly={true}
+ />
+
+
+ {isPython ? (
+
+ ) : (
+
+ )}
+
+ >
+ )
+ }
+
+ return (
+
+ {}}
+ language={snippet.language}
+ height="100%"
+ readOnly={true}
+ />
+
+ )
+}
diff --git a/src/components/features/snippet-viewer/SnippetViewerHeader.tsx b/src/components/features/snippet-viewer/SnippetViewerHeader.tsx
new file mode 100644
index 0000000..f1f7221
--- /dev/null
+++ b/src/components/features/snippet-viewer/SnippetViewerHeader.tsx
@@ -0,0 +1,96 @@
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { DialogTitle } from '@/components/ui/dialog'
+import { Copy, Pencil, Check, SplitVertical } from '@phosphor-icons/react'
+import { Snippet } from '@/lib/types'
+import { cn } from '@/lib/utils'
+import { strings, LANGUAGE_COLORS } from '@/lib/config'
+
+interface SnippetViewerHeaderProps {
+ snippet: Snippet
+ isCopied: boolean
+ canPreview: boolean
+ showPreview: boolean
+ onCopy: () => void
+ onEdit: () => void
+ onTogglePreview: () => void
+}
+
+export function SnippetViewerHeader({
+ snippet,
+ isCopied,
+ canPreview,
+ showPreview,
+ onCopy,
+ onEdit,
+ onTogglePreview,
+}: SnippetViewerHeaderProps) {
+ return (
+
+
+
+
+ {snippet.title}
+
+
+ {snippet.language}
+
+
+ {snippet.description && (
+
+ {snippet.description}
+
+ )}
+
+ {strings.snippetViewer.lastUpdated}: {new Date(snippet.updatedAt).toLocaleString()}
+
+
+
+ {canPreview && (
+
+
+ {showPreview ? strings.snippetViewer.buttons.hidePreview : strings.snippetViewer.buttons.showPreview}
+
+ )}
+
+ {isCopied ? (
+ <>
+
+ {strings.snippetViewer.buttons.copied}
+ >
+ ) : (
+ <>
+
+ {strings.snippetViewer.buttons.copy}
+ >
+ )}
+
+
+
+ {strings.snippetViewer.buttons.edit}
+
+
+
+ )
+}
diff --git a/src/components/settings/BackendAutoConfigCard.tsx b/src/components/settings/BackendAutoConfigCard.tsx
new file mode 100644
index 0000000..e932227
--- /dev/null
+++ b/src/components/settings/BackendAutoConfigCard.tsx
@@ -0,0 +1,72 @@
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { CloudCheck, CloudSlash } from '@phosphor-icons/react'
+
+interface BackendAutoConfigCardProps {
+ envVarSet: boolean
+ flaskUrl: string
+ flaskConnectionStatus: 'unknown' | 'connected' | 'failed'
+ testingConnection: boolean
+ onTestConnection: () => Promise
+}
+
+export function BackendAutoConfigCard({
+ envVarSet,
+ flaskUrl,
+ flaskConnectionStatus,
+ testingConnection,
+ onTestConnection
+}: BackendAutoConfigCardProps) {
+ if (!envVarSet) return null
+
+ return (
+
+
+
+
+ Backend Auto-Configured
+
+
+ Flask backend is configured via environment variable
+
+
+
+
+
+ Backend URL
+ {flaskUrl}
+
+
+ Configuration Source
+ VITE_FLASK_BACKEND_URL
+
+
+ Status
+ {flaskConnectionStatus === 'connected' && (
+
+
+ Connected
+
+ )}
+ {flaskConnectionStatus === 'failed' && (
+
+
+ Connection Failed
+
+ )}
+ {flaskConnectionStatus === 'unknown' && (
+
+ {testingConnection ? 'Testing...' : 'Test Connection'}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/settings/DatabaseActionsCard.tsx b/src/components/settings/DatabaseActionsCard.tsx
new file mode 100644
index 0000000..a2b4fce
--- /dev/null
+++ b/src/components/settings/DatabaseActionsCard.tsx
@@ -0,0 +1,84 @@
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Database, Download, Upload, Trash } from '@phosphor-icons/react'
+
+interface DatabaseActionsCardProps {
+ onExport: () => Promise
+ onImport: (event: React.ChangeEvent) => Promise
+ onSeed: () => Promise
+ onClear: () => Promise
+}
+
+export function DatabaseActionsCard({
+ onExport,
+ onImport,
+ onSeed,
+ onClear
+}: DatabaseActionsCardProps) {
+ return (
+
+
+ Database Actions
+
+ Backup, restore, or reset your database
+
+
+
+
+
Export Database
+
+ Download your database as a file for backup or transfer to another device
+
+
+
+ Export Database
+
+
+
+
+
Import Database
+
+ Restore a previously exported database file
+
+
+
+
+
+
+ Import Database
+
+
+
+
+
+
+
Sample Data
+
+ Add sample code snippets to get started (only if database is empty)
+
+
+
+ Add Sample Data
+
+
+
+
+
Clear All Data
+
+ Permanently delete all snippets and templates. This cannot be undone.
+
+
+
+ Clear Database
+
+
+
+
+ )
+}
diff --git a/src/components/settings/DatabaseStatsCard.tsx b/src/components/settings/DatabaseStatsCard.tsx
new file mode 100644
index 0000000..b9b5b2b
--- /dev/null
+++ b/src/components/settings/DatabaseStatsCard.tsx
@@ -0,0 +1,55 @@
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
+import { Database } from '@phosphor-icons/react'
+
+interface DatabaseStatsCardProps {
+ loading: boolean
+ stats: {
+ snippetCount: number
+ templateCount: number
+ storageType: 'indexeddb' | 'localstorage' | 'none'
+ databaseSize: number
+ } | null
+ formatBytes: (bytes: number) => string
+}
+
+export function DatabaseStatsCard({ loading, stats, formatBytes }: DatabaseStatsCardProps) {
+ return (
+
+
+
+
+ Database Statistics
+
+
+ Information about your local database storage
+
+
+
+ {loading ? (
+ Loading...
+ ) : stats ? (
+
+
+ Snippets
+ {stats.snippetCount}
+
+
+ Templates
+ {stats.templateCount}
+
+
+ Storage Type
+ {stats.storageType}
+
+
+ Database Size
+ {formatBytes(stats.databaseSize)}
+
+
+ ) : (
+ Failed to load statistics
+ )}
+
+
+ )
+}
diff --git a/src/components/settings/SchemaHealthCard.tsx b/src/components/settings/SchemaHealthCard.tsx
new file mode 100644
index 0000000..bdb0cff
--- /dev/null
+++ b/src/components/settings/SchemaHealthCard.tsx
@@ -0,0 +1,67 @@
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Warning, FirstAid, CheckCircle } from '@phosphor-icons/react'
+
+interface SchemaHealthCardProps {
+ schemaHealth: 'unknown' | 'healthy' | 'corrupted'
+ checkingSchema: boolean
+ onClear: () => Promise
+ onCheckSchema: () => Promise
+}
+
+export function SchemaHealthCard({
+ schemaHealth,
+ checkingSchema,
+ onClear,
+ onCheckSchema
+}: SchemaHealthCardProps) {
+ if (schemaHealth === 'unknown') return null
+
+ if (schemaHealth === 'corrupted') {
+ return (
+
+
+
+
+ Schema Corruption Detected
+
+
+ Your database schema is outdated or corrupted and needs to be repaired
+
+
+
+
+
+ The database schema is missing required tables or columns (likely due to namespace feature addition).
+ This can cause errors when loading or saving snippets. Click the button below to wipe and recreate the database with the correct schema.
+
+
+
+
+
+ Repair Database (Wipe & Recreate)
+
+
+ {checkingSchema ? 'Checking...' : 'Re-check Schema'}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ Schema Healthy
+
+
+ Your database schema is up to date and functioning correctly
+
+
+
+ )
+}
diff --git a/src/components/settings/StorageBackendCard.tsx b/src/components/settings/StorageBackendCard.tsx
new file mode 100644
index 0000000..80d8d1c
--- /dev/null
+++ b/src/components/settings/StorageBackendCard.tsx
@@ -0,0 +1,157 @@
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
+import { Database, CloudArrowUp, CloudCheck, CloudSlash, Upload, Download } from '@phosphor-icons/react'
+import { type StorageBackend } from '@/lib/storage'
+
+interface StorageBackendCardProps {
+ storageBackend: StorageBackend
+ flaskUrl: string
+ flaskConnectionStatus: 'unknown' | 'connected' | 'failed'
+ testingConnection: boolean
+ envVarSet: boolean
+ onStorageBackendChange: (backend: StorageBackend) => void
+ onFlaskUrlChange: (url: string) => void
+ onTestConnection: () => Promise
+ onSaveConfig: () => Promise
+ onMigrateToFlask: () => Promise
+ onMigrateToIndexedDB: () => Promise
+}
+
+export function StorageBackendCard({
+ storageBackend,
+ flaskUrl,
+ flaskConnectionStatus,
+ testingConnection,
+ envVarSet,
+ onStorageBackendChange,
+ onFlaskUrlChange,
+ onTestConnection,
+ onSaveConfig,
+ onMigrateToFlask,
+ onMigrateToIndexedDB,
+}: StorageBackendCardProps) {
+ return (
+
+
+
+
+ Storage Backend
+
+
+ Choose where your snippets are stored
+
+
+
+ {envVarSet && (
+
+
+
+
+ Storage backend is configured via VITE_FLASK_BACKEND_URL environment variable and cannot be changed here.
+
+
+
+ )}
+
+ onStorageBackendChange(value as StorageBackend)}
+ disabled={envVarSet}
+ >
+
+
+
+
+ IndexedDB (Local Browser Storage)
+
+
+ Store snippets locally in your browser. Data persists on this device only.
+
+
+
+
+
+
+
+
+ Flask Backend (Remote Server)
+
+
+ Store snippets on a Flask backend server. Data is accessible from any device.
+
+
+
+
+
+ {storageBackend === 'flask' && (
+
+
+
Flask Backend URL
+
+ onFlaskUrlChange(e.target.value)}
+ disabled={envVarSet}
+ />
+
+ {testingConnection ? 'Testing...' : 'Test'}
+
+
+ {flaskConnectionStatus === 'connected' && (
+
+
+ Connected successfully
+
+ )}
+ {flaskConnectionStatus === 'failed' && (
+
+
+ Connection failed
+
+ )}
+
+
+
+
+
+ Migrate IndexedDB Data to Flask
+
+
+
+ Migrate Flask Data to IndexedDB
+
+
+
+ )}
+
+
+
+
+ Save Storage Settings
+
+
+
+
+ )
+}
diff --git a/src/components/settings/StorageInfoCard.tsx b/src/components/settings/StorageInfoCard.tsx
new file mode 100644
index 0000000..41d7c76
--- /dev/null
+++ b/src/components/settings/StorageInfoCard.tsx
@@ -0,0 +1,40 @@
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+
+interface StorageInfoCardProps {
+ storageType?: 'indexeddb' | 'localstorage' | 'none'
+}
+
+export function StorageInfoCard({ storageType }: StorageInfoCardProps) {
+ return (
+
+
+ Storage Information
+
+ How your data is stored
+
+
+
+
+
+ {storageType === 'indexeddb' ? (
+ <>
+ IndexedDB is being used for storage. This provides better performance and
+ larger storage capacity compared to localStorage. Your data persists locally in your browser.
+ >
+ ) : storageType === 'localstorage' ? (
+ <>
+ localStorage is being used for storage. IndexedDB is not available in your
+ browser. Note that localStorage has a smaller storage limit (typically 5-10MB).
+ >
+ ) : (
+ <>
+ No persistent storage detected. Your data will be lost when you close the browser.
+ >
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/ui/sidebar-context.tsx b/src/components/ui/sidebar-context.tsx
new file mode 100644
index 0000000..7406539
--- /dev/null
+++ b/src/components/ui/sidebar-context.tsx
@@ -0,0 +1,132 @@
+"use client"
+
+import { CSSProperties, ComponentProps, createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
+import { useIsMobile } from "@/hooks/use-mobile"
+import { cn } from "@/lib/utils"
+import { TooltipProvider } from "@/components/ui/tooltip"
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state"
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
+export const SIDEBAR_WIDTH = "16rem"
+export const SIDEBAR_WIDTH_MOBILE = "18rem"
+export const SIDEBAR_WIDTH_ICON = "3rem"
+const SIDEBAR_KEYBOARD_SHORTCUT = "b"
+
+export type SidebarContextProps = {
+ state: "expanded" | "collapsed"
+ open: boolean
+ setOpen: (open: boolean) => void
+ openMobile: boolean
+ setOpenMobile: (open: boolean) => void
+ isMobile: boolean
+ toggleSidebar: () => void
+}
+
+const SidebarContext = createContext(null)
+
+export function useSidebar() {
+ const context = useContext(SidebarContext)
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.")
+ }
+
+ return context
+}
+
+export function SidebarProvider({
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+}: ComponentProps<"div"> & {
+ defaultOpen?: boolean
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}) {
+ const isMobile = useIsMobile()
+ const [openMobile, setOpenMobile] = useState(false)
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = useState(defaultOpen)
+ const open = openProp ?? _open
+ const setOpen = useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value
+ if (setOpenProp) {
+ setOpenProp(openState)
+ } else {
+ _setOpen(openState)
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
+ },
+ [setOpenProp, open]
+ )
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = useCallback(() => {
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
+ }, [isMobile, setOpen, setOpenMobile])
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault()
+ toggleSidebar()
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [toggleSidebar])
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed"
+
+ const contextValue = useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
+ )
+
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/src/components/ui/sidebar-core.tsx b/src/components/ui/sidebar-core.tsx
new file mode 100644
index 0000000..3679772
--- /dev/null
+++ b/src/components/ui/sidebar-core.tsx
@@ -0,0 +1,181 @@
+"use client"
+
+import { CSSProperties, ComponentProps } from "react"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { useSidebar, SIDEBAR_WIDTH_MOBILE } from "./sidebar-context"
+import PanelLeftIcon from "lucide-react/dist/esm/icons/panel-left"
+
+export function Sidebar({
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ ...props
+}: ComponentProps<"div"> & {
+ side?: "left" | "right"
+ variant?: "sidebar" | "floating" | "inset"
+ collapsible?: "offcanvas" | "icon" | "none"
+}) {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ )
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ )
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ )
+}
+
+export function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: ComponentProps) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+ {
+ onClick?.(event)
+ toggleSidebar()
+ }}
+ {...props}
+ >
+
+ Toggle Sidebar
+
+ )
+}
+
+export function SidebarRail({ className, ...props }: ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+}
+
+export function SidebarInset({ className, ...props }: ComponentProps<"main">) {
+ return (
+
+ )
+}
diff --git a/src/components/ui/sidebar-menu.tsx b/src/components/ui/sidebar-menu.tsx
new file mode 100644
index 0000000..a1153c4
--- /dev/null
+++ b/src/components/ui/sidebar-menu.tsx
@@ -0,0 +1,318 @@
+"use client"
+
+import { CSSProperties, ComponentProps, useMemo } from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { VariantProps, cva } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { useSidebar } from "./sidebar-context"
+
+export function SidebarGroupLabel({
+ className,
+ asChild = false,
+ ...props
+}: ComponentProps<"div"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "div"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export function SidebarGroupAction({
+ className,
+ asChild = false,
+ ...props
+}: ComponentProps<"button"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 md:after:hidden",
+ "group-data-[collapsible=icon]:hidden",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export function SidebarGroupContent({
+ className,
+ ...props
+}: ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export function SidebarMenu({ className, ...props }: ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+export function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden 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",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "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))]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export function SidebarMenuButton({
+ asChild = false,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+}: ComponentProps<"button"> & {
+ asChild?: boolean
+ isActive?: boolean
+ tooltip?: string | ComponentProps
+} & VariantProps) {
+ const Comp = asChild ? Slot : "button"
+ const { isMobile, state } = useSidebar()
+
+ const button = (
+
+ )
+
+ if (!tooltip) {
+ return button
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ }
+ }
+
+ return (
+
+ {button}
+
+
+ )
+}
+
+export function SidebarMenuAction({
+ className,
+ asChild = false,
+ showOnHover = false,
+ ...props
+}: ComponentProps<"button"> & {
+ asChild?: boolean
+ showOnHover?: boolean
+}) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 md:after:hidden",
+ "peer-data-[size=sm]/menu-button:top-1",
+ "peer-data-[size=default]/menu-button:top-1.5",
+ "peer-data-[size=lg]/menu-button:top-2.5",
+ "group-data-[collapsible=icon]:hidden",
+ showOnHover &&
+ "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export function SidebarMenuBadge({
+ className,
+ ...props
+}: ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: ComponentProps<"div"> & {
+ showIcon?: boolean
+}) {
+ // Random width between 50 to 90%.
+ const width = useMemo(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`
+ }, [])
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ )
+}
+
+export function SidebarMenuSub({ className, ...props }: ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+export function SidebarMenuSubItem({
+ className,
+ ...props
+}: ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+export function SidebarMenuSubButton({
+ asChild = false,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: ComponentProps<"a"> & {
+ asChild?: boolean
+ size?: "sm" | "md"
+ isActive?: boolean
+}) {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+ svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
+ size === "sm" && "text-xs",
+ size === "md" && "text-sm",
+ "group-data-[collapsible=icon]:hidden",
+ className
+ )}
+ {...props}
+ />
+ )
+}
diff --git a/src/components/ui/sidebar-parts.tsx b/src/components/ui/sidebar-parts.tsx
new file mode 100644
index 0000000..76b7c49
--- /dev/null
+++ b/src/components/ui/sidebar-parts.tsx
@@ -0,0 +1,81 @@
+"use client"
+
+import { ComponentProps } from "react"
+import { cn } from "@/lib/utils"
+import { Input } from "@/components/ui/input"
+import { Separator } from "@/components/ui/separator"
+
+export function SidebarInput({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ )
+}
+
+export function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export function SidebarSeparator({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ )
+}
+
+export function SidebarContent({ className, ...props }: ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export function SidebarGroup({ className, ...props }: ComponentProps<"div">) {
+ return (
+
+ )
+}
diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx
index 3b351b2..1fe6940 100644
--- a/src/components/ui/sidebar.tsx
+++ b/src/components/ui/sidebar.tsx
@@ -1,726 +1,26 @@
-"use client"
-
-import { CSSProperties, ComponentProps, createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
-import { Slot } from "@radix-ui/react-slot"
-import { VariantProps, cva } from "class-variance-authority"
-import PanelLeftIcon from "lucide-react/dist/esm/icons/panel-left"
-
-import { useIsMobile } from "@/hooks/use-mobile"
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Separator } from "@/components/ui/separator"
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Skeleton } from "@/components/ui/skeleton"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-const SIDEBAR_COOKIE_NAME = "sidebar_state"
-const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
-const SIDEBAR_WIDTH = "16rem"
-const SIDEBAR_WIDTH_MOBILE = "18rem"
-const SIDEBAR_WIDTH_ICON = "3rem"
-const SIDEBAR_KEYBOARD_SHORTCUT = "b"
-
-type SidebarContextProps = {
- state: "expanded" | "collapsed"
- open: boolean
- setOpen: (open: boolean) => void
- openMobile: boolean
- setOpenMobile: (open: boolean) => void
- isMobile: boolean
- toggleSidebar: () => void
-}
-
-const SidebarContext = createContext(null)
-
-function useSidebar() {
- const context = useContext(SidebarContext)
- if (!context) {
- throw new Error("useSidebar must be used within a SidebarProvider.")
- }
-
- return context
-}
-
-function SidebarProvider({
- defaultOpen = true,
- open: openProp,
- onOpenChange: setOpenProp,
- className,
- style,
- children,
- ...props
-}: ComponentProps<"div"> & {
- defaultOpen?: boolean
- open?: boolean
- onOpenChange?: (open: boolean) => void
-}) {
- const isMobile = useIsMobile()
- const [openMobile, setOpenMobile] = useState(false)
-
- // This is the internal state of the sidebar.
- // We use openProp and setOpenProp for control from outside the component.
- const [_open, _setOpen] = useState(defaultOpen)
- const open = openProp ?? _open
- const setOpen = useCallback(
- (value: boolean | ((value: boolean) => boolean)) => {
- const openState = typeof value === "function" ? value(open) : value
- if (setOpenProp) {
- setOpenProp(openState)
- } else {
- _setOpen(openState)
- }
-
- // This sets the cookie to keep the sidebar state.
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
- },
- [setOpenProp, open]
- )
-
- // Helper to toggle the sidebar.
- const toggleSidebar = useCallback(() => {
- return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
- }, [isMobile, setOpen, setOpenMobile])
-
- // Adds a keyboard shortcut to toggle the sidebar.
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (
- event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
- (event.metaKey || event.ctrlKey)
- ) {
- event.preventDefault()
- toggleSidebar()
- }
- }
-
- window.addEventListener("keydown", handleKeyDown)
- return () => window.removeEventListener("keydown", handleKeyDown)
- }, [toggleSidebar])
-
- // We add a state so that we can do data-state="expanded" or "collapsed".
- // This makes it easier to style the sidebar with Tailwind classes.
- const state = open ? "expanded" : "collapsed"
-
- const contextValue = useMemo(
- () => ({
- state,
- open,
- setOpen,
- isMobile,
- openMobile,
- setOpenMobile,
- toggleSidebar,
- }),
- [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
- )
-
- return (
-
-
-
- {children}
-
-
-
- )
-}
-
-function Sidebar({
- side = "left",
- variant = "sidebar",
- collapsible = "offcanvas",
- className,
- children,
- ...props
-}: ComponentProps<"div"> & {
- side?: "left" | "right"
- variant?: "sidebar" | "floating" | "inset"
- collapsible?: "offcanvas" | "icon" | "none"
-}) {
- const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
-
- if (collapsible === "none") {
- return (
-
- {children}
-
- )
- }
-
- if (isMobile) {
- return (
-
-
-
- Sidebar
- Displays the mobile sidebar.
-
- {children}
-
-
- )
- }
-
- return (
-
- {/* This is what handles the sidebar gap on desktop */}
-
-
-
- )
-}
-
-function SidebarTrigger({
- className,
- onClick,
- ...props
-}: ComponentProps) {
- const { toggleSidebar } = useSidebar()
-
- return (
- {
- onClick?.(event)
- toggleSidebar()
- }}
- {...props}
- >
-
- Toggle Sidebar
-
- )
-}
-
-function SidebarRail({ className, ...props }: ComponentProps<"button">) {
- const { toggleSidebar } = useSidebar()
-
- return (
-
- )
-}
-
-function SidebarInset({ className, ...props }: ComponentProps<"main">) {
- return (
-
- )
-}
-
-function SidebarInput({
- className,
- ...props
-}: ComponentProps) {
- return (
-
- )
-}
-
-function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
- return (
-
- )
-}
-
-function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
- return (
-
- )
-}
-
-function SidebarSeparator({
- className,
- ...props
-}: ComponentProps) {
- return (
-
- )
-}
-
-function SidebarContent({ className, ...props }: ComponentProps<"div">) {
- return (
-
- )
-}
-
-function SidebarGroup({ className, ...props }: ComponentProps<"div">) {
- return (
-
- )
-}
-
-function SidebarGroupLabel({
- className,
- asChild = false,
- ...props
-}: ComponentProps<"div"> & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "div"
-
- return (
- svg]:size-4 [&>svg]:shrink-0",
- "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
- className
- )}
- {...props}
- />
- )
-}
-
-function SidebarGroupAction({
- className,
- asChild = false,
- ...props
-}: ComponentProps<"button"> & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "button"
-
- return (
- svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 md:after:hidden",
- "group-data-[collapsible=icon]:hidden",
- className
- )}
- {...props}
- />
- )
-}
-
-function SidebarGroupContent({
- className,
- ...props
-}: ComponentProps<"div">) {
- return (
-
- )
-}
-
-function SidebarMenu({ className, ...props }: ComponentProps<"ul">) {
- return (
-
- )
-}
-
-function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) {
- return (
-
- )
-}
-
-const sidebarMenuButtonVariants = cva(
- "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden 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",
- {
- variants: {
- variant: {
- default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
- outline:
- "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))]",
- },
- size: {
- default: "h-8 text-sm",
- sm: "h-7 text-xs",
- lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-)
-
-function SidebarMenuButton({
- asChild = false,
- isActive = false,
- variant = "default",
- size = "default",
- tooltip,
- className,
- ...props
-}: ComponentProps<"button"> & {
- asChild?: boolean
- isActive?: boolean
- tooltip?: string | ComponentProps
-} & VariantProps) {
- const Comp = asChild ? Slot : "button"
- const { isMobile, state } = useSidebar()
-
- const button = (
-
- )
-
- if (!tooltip) {
- return button
- }
-
- if (typeof tooltip === "string") {
- tooltip = {
- children: tooltip,
- }
- }
-
- return (
-
- {button}
-
-
- )
-}
-
-function SidebarMenuAction({
- className,
- asChild = false,
- showOnHover = false,
- ...props
-}: ComponentProps<"button"> & {
- asChild?: boolean
- showOnHover?: boolean
-}) {
- const Comp = asChild ? Slot : "button"
-
- return (
- svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 md:after:hidden",
- "peer-data-[size=sm]/menu-button:top-1",
- "peer-data-[size=default]/menu-button:top-1.5",
- "peer-data-[size=lg]/menu-button:top-2.5",
- "group-data-[collapsible=icon]:hidden",
- showOnHover &&
- "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
- className
- )}
- {...props}
- />
- )
-}
-
-function SidebarMenuBadge({
- className,
- ...props
-}: ComponentProps<"div">) {
- return (
-
- )
-}
-
-function SidebarMenuSkeleton({
- className,
- showIcon = false,
- ...props
-}: ComponentProps<"div"> & {
- showIcon?: boolean
-}) {
- // Random width between 50 to 90%.
- const width = useMemo(() => {
- return `${Math.floor(Math.random() * 40) + 50}%`
- }, [])
-
- return (
-
- {showIcon && (
-
- )}
-
-
- )
-}
-
-function SidebarMenuSub({ className, ...props }: ComponentProps<"ul">) {
- return (
-
- )
-}
-
-function SidebarMenuSubItem({
- className,
- ...props
-}: ComponentProps<"li">) {
- return (
-
- )
-}
-
-function SidebarMenuSubButton({
- asChild = false,
- size = "md",
- isActive = false,
- className,
- ...props
-}: ComponentProps<"a"> & {
- asChild?: boolean
- size?: "sm" | "md"
- isActive?: boolean
-}) {
- const Comp = asChild ? Slot : "a"
-
- return (
- svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
- "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
- size === "sm" && "text-xs",
- size === "md" && "text-sm",
- "group-data-[collapsible=icon]:hidden",
- className
- )}
- {...props}
- />
- )
-}
-
+// Re-export all sidebar components from their modular files
+export { SidebarProvider, useSidebar } from "./sidebar-context"
+export { Sidebar, SidebarTrigger, SidebarRail, SidebarInset } from "./sidebar-core"
export {
- Sidebar,
- SidebarContent,
+ SidebarInput,
+ SidebarHeader,
SidebarFooter,
+ SidebarSeparator,
+ SidebarContent,
SidebarGroup,
+} from "./sidebar-parts"
+export {
+ SidebarGroupLabel,
SidebarGroupAction,
SidebarGroupContent,
- SidebarGroupLabel,
- SidebarHeader,
- SidebarInput,
- SidebarInset,
SidebarMenu,
+ SidebarMenuItem,
+ SidebarMenuButton,
SidebarMenuAction,
SidebarMenuBadge,
- SidebarMenuButton,
- SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
- SidebarMenuSubButton,
SidebarMenuSubItem,
- SidebarProvider,
- SidebarRail,
- SidebarSeparator,
- SidebarTrigger,
- useSidebar,
-}
+ SidebarMenuSubButton,
+} from "./sidebar-menu"
+
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index c3eb23e..9633197 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -1,22 +1,20 @@
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
-import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Database, Download, Upload, Trash, CloudArrowUp, CloudCheck, CloudSlash, FirstAid, CheckCircle, Warning } from '@phosphor-icons/react'
import { getDatabaseStats, exportDatabase, importDatabase, clearDatabase, seedDatabase, getAllSnippets, validateDatabaseSchema } from '@/lib/db'
import { toast } from 'sonner'
-import { Alert, AlertDescription } from '@/components/ui/alert'
import {
- getStorageConfig,
saveStorageConfig,
loadStorageConfig,
FlaskStorageAdapter,
type StorageBackend
} from '@/lib/storage'
-import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { PersistenceSettings } from '@/components/demo/PersistenceSettings'
+import { SchemaHealthCard } from '@/components/settings/SchemaHealthCard'
+import { BackendAutoConfigCard } from '@/components/settings/BackendAutoConfigCard'
+import { StorageBackendCard } from '@/components/settings/StorageBackendCard'
+import { DatabaseStatsCard } from '@/components/settings/DatabaseStatsCard'
+import { StorageInfoCard } from '@/components/settings/StorageInfoCard'
+import { DatabaseActionsCard } from '@/components/settings/DatabaseActionsCard'
export function SettingsPage() {
const [stats, setStats] = useState<{
@@ -271,356 +269,52 @@ export function SettingsPage() {
- {schemaHealth === 'corrupted' && (
-
-
-
-
- Schema Corruption Detected
-
-
- Your database schema is outdated or corrupted and needs to be repaired
-
-
-
-
-
- The database schema is missing required tables or columns (likely due to namespace feature addition).
- This can cause errors when loading or saving snippets. Click the button below to wipe and recreate the database with the correct schema.
-
-
-
-
-
- Repair Database (Wipe & Recreate)
-
-
- {checkingSchema ? 'Checking...' : 'Re-check Schema'}
-
-
-
-
- )}
+
- {schemaHealth === 'healthy' && (
-
-
-
-
- Schema Healthy
-
-
- Your database schema is up to date and functioning correctly
-
-
-
- )}
-
- {envVarSet && (
-
-
-
-
- Backend Auto-Configured
-
-
- Flask backend is configured via environment variable
-
-
-
-
-
- Backend URL
- {flaskUrl}
-
-
- Configuration Source
- VITE_FLASK_BACKEND_URL
-
-
- Status
- {flaskConnectionStatus === 'connected' && (
-
-
- Connected
-
- )}
- {flaskConnectionStatus === 'failed' && (
-
-
- Connection Failed
-
- )}
- {flaskConnectionStatus === 'unknown' && (
-
- {testingConnection ? 'Testing...' : 'Test Connection'}
-
- )}
-
-
-
-
- )}
+
-
-
-
-
- Storage Backend
-
-
- Choose where your snippets are stored
-
-
-
- {envVarSet && (
-
-
-
-
- Storage backend is configured via VITE_FLASK_BACKEND_URL environment variable and cannot be changed here.
-
-
-
- )}
-
- setStorageBackend(value as StorageBackend)}
- disabled={envVarSet}
- >
-
-
-
-
- IndexedDB (Local Browser Storage)
-
-
- Store snippets locally in your browser. Data persists on this device only.
-
-
-
-
-
-
-
-
- Flask Backend (Remote Server)
-
-
- Store snippets on a Flask backend server. Data is accessible from any device.
-
-
-
-
+ {
+ setFlaskUrl(url)
+ setFlaskConnectionStatus('unknown')
+ }}
+ onTestConnection={handleTestConnection}
+ onSaveConfig={handleSaveStorageConfig}
+ onMigrateToFlask={handleMigrateToFlask}
+ onMigrateToIndexedDB={handleMigrateToIndexedDB}
+ />
- {storageBackend === 'flask' && (
-
-
-
Flask Backend URL
-
- {
- setFlaskUrl(e.target.value)
- setFlaskConnectionStatus('unknown')
- }}
- disabled={envVarSet}
- />
-
- {testingConnection ? 'Testing...' : 'Test'}
-
-
- {flaskConnectionStatus === 'connected' && (
-
-
- Connected successfully
-
- )}
- {flaskConnectionStatus === 'failed' && (
-
-
- Connection failed
-
- )}
-
+
-
-
-
- Migrate IndexedDB Data to Flask
-
-
-
- Migrate Flask Data to IndexedDB
-
-
-
- )}
+
-
-
-
- Save Storage Settings
-
-
-
-
-
-
-
-
-
- Database Statistics
-
-
- Information about your local database storage
-
-
-
- {loading ? (
- Loading...
- ) : stats ? (
-
-
- Snippets
- {stats.snippetCount}
-
-
- Templates
- {stats.templateCount}
-
-
- Storage Type
- {stats.storageType}
-
-
- Database Size
- {formatBytes(stats.databaseSize)}
-
-
- ) : (
- Failed to load statistics
- )}
-
-
-
-
-
- Storage Information
-
- How your data is stored
-
-
-
-
-
- {stats?.storageType === 'indexeddb' ? (
- <>
- IndexedDB is being used for storage. This provides better performance and
- larger storage capacity compared to localStorage. Your data persists locally in your browser.
- >
- ) : stats?.storageType === 'localstorage' ? (
- <>
- localStorage is being used for storage. IndexedDB is not available in your
- browser. Note that localStorage has a smaller storage limit (typically 5-10MB).
- >
- ) : (
- <>
- No persistent storage detected. Your data will be lost when you close the browser.
- >
- )}
-
-
-
-
-
-
-
- Database Actions
-
- Backup, restore, or reset your database
-
-
-
-
-
Export Database
-
- Download your database as a file for backup or transfer to another device
-
-
-
- Export Database
-
-
-
-
-
Import Database
-
- Restore a previously exported database file
-
-
-
-
-
-
- Import Database
-
-
-
-
-
-
-
Sample Data
-
- Add sample code snippets to get started (only if database is empty)
-
-
-
- Add Sample Data
-
-
-
-
-
Clear All Data
-
- Permanently delete all snippets and templates. This cannot be undone.
-
-
-
- Clear Database
-
-
-
-
+
)