Merge pull request #3 from johndoe6345789/copilot/split-large-components

[WIP] Refactor components larger than 150 LOC
This commit is contained in:
2026-01-17 22:05:22 +00:00
committed by GitHub
19 changed files with 1637 additions and 1311 deletions

View File

@@ -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}
>
<div className="p-6 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
{selectionMode && (
<Checkbox
checked={isSelected}
onCheckedChange={handleToggleSelect}
onClick={(e) => e.stopPropagation()}
className="mt-1"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1 truncate">
{snippet.title}
</h3>
{snippetData.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{snippetData.description}
</p>
)}
</div>
</div>
<Badge
className={`shrink-0 ${LANGUAGE_COLORS[snippet.language] || LANGUAGE_COLORS['Other']}`}
>
{snippet.language}
</Badge>
</div>
<SnippetCardHeader
snippet={snippet}
description={snippetData.description}
selectionMode={selectionMode}
isSelected={isSelected}
onToggleSelect={handleToggleSelect}
/>
<div className="rounded-md bg-secondary/30 p-3 border border-border">
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words font-mono">
{snippetData.displayCode}
</pre>
{snippetData.isTruncated && (
<p className="text-xs text-accent mt-2">
{strings.snippetCard.viewFullCode}
</p>
)}
</div>
<SnippetCodePreview
displayCode={snippetData.displayCode}
isTruncated={snippetData.isTruncated}
/>
{!selectionMode && (
<div className="flex items-center justify-between gap-2 pt-2">
<div className="flex-1 flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleView}
className="gap-2"
>
<Eye className="h-4 w-4" />
{strings.snippetCard.viewButton}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="gap-2"
aria-label={strings.snippetCard.ariaLabels.copy}
>
<Copy className="h-4 w-4" />
{isCopied ? strings.snippetCard.copiedButton : strings.snippetCard.copyButton}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleEdit}
aria-label={strings.snippetCard.ariaLabels.edit}
>
<Pencil className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label="More options"
>
<DotsThree className="h-4 w-4" weight="bold" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={isMoving || availableNamespaces.length === 0}>
<FolderOpen className="h-4 w-4 mr-2" />
<span>Move to...</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{availableNamespaces.length === 0 ? (
<DropdownMenuItem disabled>
No other namespaces
</DropdownMenuItem>
) : (
availableNamespaces.map((namespace) => (
<DropdownMenuItem
key={namespace.id}
onClick={() => handleMoveToNamespace(namespace.id)}
>
{namespace.name}
{namespace.isDefault && (
<span className="ml-2 text-xs text-muted-foreground">(Default)</span>
)}
</DropdownMenuItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
className="text-destructive focus:text-destructive"
>
<Trash className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<SnippetCardActions
isCopied={isCopied}
isMoving={isMoving}
availableNamespaces={availableNamespaces}
onView={handleView}
onCopy={handleCopy}
onEdit={handleEdit}
onDelete={handleDelete}
onMoveToNamespace={handleMoveToNamespace}
/>
)}
</div>
</Card>

View File

@@ -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 (
<div className="flex items-center justify-between gap-2 pt-2">
<div className="flex-1 flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onView}
className="gap-2"
>
<Eye className="h-4 w-4" />
{strings.snippetCard.viewButton}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onCopy}
className="gap-2"
aria-label={strings.snippetCard.ariaLabels.copy}
>
<Copy className="h-4 w-4" />
{isCopied ? strings.snippetCard.copiedButton : strings.snippetCard.copyButton}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onEdit}
aria-label={strings.snippetCard.ariaLabels.edit}
>
<Pencil className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label="More options"
>
<DotsThree className="h-4 w-4" weight="bold" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={isMoving || availableNamespaces.length === 0}>
<FolderOpen className="h-4 w-4 mr-2" />
<span>Move to...</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{availableNamespaces.length === 0 ? (
<DropdownMenuItem disabled>
No other namespaces
</DropdownMenuItem>
) : (
availableNamespaces.map((namespace) => (
<DropdownMenuItem
key={namespace.id}
onClick={() => onMoveToNamespace(namespace.id)}
>
{namespace.name}
{namespace.isDefault && (
<span className="ml-2 text-xs text-muted-foreground">(Default)</span>
)}
</DropdownMenuItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}

View File

@@ -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 (
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
{selectionMode && (
<Checkbox
checked={isSelected}
onCheckedChange={onToggleSelect}
onClick={(e) => e.stopPropagation()}
className="mt-1"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1 truncate">
{snippet.title}
</h3>
{description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{description}
</p>
)}
</div>
</div>
<Badge
className={`shrink-0 ${LANGUAGE_COLORS[snippet.language] || LANGUAGE_COLORS['Other']}`}
>
{snippet.language}
</Badge>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { strings } from '@/lib/config'
interface SnippetCodePreviewProps {
displayCode: string
isTruncated: boolean
}
export function SnippetCodePreview({ displayCode, isTruncated }: SnippetCodePreviewProps) {
return (
<div className="rounded-md bg-secondary/30 p-3 border border-border">
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words font-mono">
{displayCode}
</pre>
{isTruncated && (
<p className="text-xs text-accent mt-2">
{strings.snippetCard.viewFullCode}
</p>
)}
</div>
)
}

View File

@@ -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
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[95vw] sm:max-h-[95vh] h-[95vh] overflow-hidden flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-3">
<DialogTitle className="text-2xl font-bold truncate">
{snippet.title}
</DialogTitle>
<Badge
variant="outline"
className={cn(
"shrink-0 border font-medium text-xs px-2 py-1",
LANGUAGE_COLORS[snippet.language] || LANGUAGE_COLORS['Other']
)}
>
{snippet.language}
</Badge>
</div>
{snippet.description && (
<p className="text-sm text-muted-foreground">
{snippet.description}
</p>
)}
<p className="text-xs text-muted-foreground">
{strings.snippetViewer.lastUpdated}: {new Date(snippet.updatedAt).toLocaleString()}
</p>
</div>
<div className="flex gap-2 shrink-0">
{canPreview && (
<Button
variant={showPreview ? "default" : "outline"}
size="sm"
onClick={() => setShowPreview(!showPreview)}
className="gap-2"
>
<SplitVertical className="h-4 w-4" />
{showPreview ? strings.snippetViewer.buttons.hidePreview : strings.snippetViewer.buttons.showPreview}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="gap-2"
>
{isCopied ? (
<>
<Check className="h-4 w-4" weight="bold" />
{strings.snippetViewer.buttons.copied}
</>
) : (
<>
<Copy className="h-4 w-4" />
{strings.snippetViewer.buttons.copy}
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleEdit}
className="gap-2"
>
<Pencil className="h-4 w-4" />
{strings.snippetViewer.buttons.edit}
</Button>
</div>
</div>
<SnippetViewerHeader
snippet={snippet}
isCopied={isCopied}
canPreview={canPreview}
showPreview={showPreview}
onCopy={handleCopy}
onEdit={handleEdit}
onTogglePreview={() => setShowPreview(!showPreview)}
/>
</DialogHeader>
<div className="flex-1 overflow-hidden flex">
{canPreview && showPreview ? (
<>
<div className="flex-1 overflow-hidden border-r border-border">
<MonacoEditor
value={snippet.code}
onChange={() => {}}
language={snippet.language}
height="100%"
readOnly={true}
/>
</div>
<div className="flex-1 overflow-hidden">
{isPython ? (
<PythonOutput code={snippet.code} />
) : (
<ReactPreview
code={snippet.code}
language={snippet.language}
functionName={snippet.functionName}
inputParameters={snippet.inputParameters}
/>
)}
</div>
</>
) : (
<div className="flex-1 overflow-hidden">
<MonacoEditor
value={snippet.code}
onChange={() => {}}
language={snippet.language}
height="100%"
readOnly={true}
/>
</div>
)}
<SnippetViewerContent
snippet={snippet}
canPreview={canPreview}
showPreview={showPreview}
isPython={isPython}
/>
</div>
</DialogContent>
</Dialog>

View File

@@ -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 (
<>
<div className="flex-1 overflow-hidden border-r border-border">
<MonacoEditor
value={snippet.code}
onChange={() => {}}
language={snippet.language}
height="100%"
readOnly={true}
/>
</div>
<div className="flex-1 overflow-hidden">
{isPython ? (
<PythonOutput code={snippet.code} />
) : (
<ReactPreview
code={snippet.code}
language={snippet.language}
functionName={snippet.functionName}
inputParameters={snippet.inputParameters}
/>
)}
</div>
</>
)
}
return (
<div className="flex-1 overflow-hidden">
<MonacoEditor
value={snippet.code}
onChange={() => {}}
language={snippet.language}
height="100%"
readOnly={true}
/>
</div>
)
}

View File

@@ -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 (
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-3">
<DialogTitle className="text-2xl font-bold truncate">
{snippet.title}
</DialogTitle>
<Badge
variant="outline"
className={cn(
"shrink-0 border font-medium text-xs px-2 py-1",
LANGUAGE_COLORS[snippet.language] || LANGUAGE_COLORS['Other']
)}
>
{snippet.language}
</Badge>
</div>
{snippet.description && (
<p className="text-sm text-muted-foreground">
{snippet.description}
</p>
)}
<p className="text-xs text-muted-foreground">
{strings.snippetViewer.lastUpdated}: {new Date(snippet.updatedAt).toLocaleString()}
</p>
</div>
<div className="flex gap-2 shrink-0">
{canPreview && (
<Button
variant={showPreview ? "default" : "outline"}
size="sm"
onClick={onTogglePreview}
className="gap-2"
>
<SplitVertical className="h-4 w-4" />
{showPreview ? strings.snippetViewer.buttons.hidePreview : strings.snippetViewer.buttons.showPreview}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={onCopy}
className="gap-2"
>
{isCopied ? (
<>
<Check className="h-4 w-4" weight="bold" />
{strings.snippetViewer.buttons.copied}
</>
) : (
<>
<Copy className="h-4 w-4" />
{strings.snippetViewer.buttons.copy}
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={onEdit}
className="gap-2"
>
<Pencil className="h-4 w-4" />
{strings.snippetViewer.buttons.edit}
</Button>
</div>
</div>
)
}

View File

@@ -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<void>
}
export function BackendAutoConfigCard({
envVarSet,
flaskUrl,
flaskConnectionStatus,
testingConnection,
onTestConnection
}: BackendAutoConfigCardProps) {
if (!envVarSet) return null
return (
<Card className="border-accent">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-accent">
<CloudCheck weight="fill" size={24} />
Backend Auto-Configured
</CardTitle>
<CardDescription>
Flask backend is configured via environment variable
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">Backend URL</span>
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">{flaskUrl}</code>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">Configuration Source</span>
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">VITE_FLASK_BACKEND_URL</code>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">Status</span>
{flaskConnectionStatus === 'connected' && (
<span className="flex items-center gap-2 text-sm text-green-600">
<CloudCheck weight="fill" size={16} />
Connected
</span>
)}
{flaskConnectionStatus === 'failed' && (
<span className="flex items-center gap-2 text-sm text-destructive">
<CloudSlash weight="fill" size={16} />
Connection Failed
</span>
)}
{flaskConnectionStatus === 'unknown' && (
<Button
onClick={onTestConnection}
variant="outline"
size="sm"
disabled={testingConnection}
>
{testingConnection ? 'Testing...' : 'Test Connection'}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -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<void>
onImport: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>
onSeed: () => Promise<void>
onClear: () => Promise<void>
}
export function DatabaseActionsCard({
onExport,
onImport,
onSeed,
onClear
}: DatabaseActionsCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>Database Actions</CardTitle>
<CardDescription>
Backup, restore, or reset your database
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-2">Export Database</h3>
<p className="text-sm text-muted-foreground mb-3">
Download your database as a file for backup or transfer to another device
</p>
<Button onClick={onExport} variant="outline" className="gap-2">
<Download weight="bold" size={16} />
Export Database
</Button>
</div>
<div className="pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2">Import Database</h3>
<p className="text-sm text-muted-foreground mb-3">
Restore a previously exported database file
</p>
<label>
<input
type="file"
accept=".db"
onChange={onImport}
className="hidden"
id="import-db"
/>
<Button variant="outline" className="gap-2" asChild>
<span>
<Upload weight="bold" size={16} />
Import Database
</span>
</Button>
</label>
</div>
<div className="pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2">Sample Data</h3>
<p className="text-sm text-muted-foreground mb-3">
Add sample code snippets to get started (only if database is empty)
</p>
<Button onClick={onSeed} variant="outline" className="gap-2">
<Database weight="bold" size={16} />
Add Sample Data
</Button>
</div>
<div className="pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2 text-destructive">Clear All Data</h3>
<p className="text-sm text-muted-foreground mb-3">
Permanently delete all snippets and templates. This cannot be undone.
</p>
<Button onClick={onClear} variant="destructive" className="gap-2">
<Trash weight="bold" size={16} />
Clear Database
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database weight="duotone" size={24} />
Database Statistics
</CardTitle>
<CardDescription>
Information about your local database storage
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-muted-foreground">Loading...</p>
) : stats ? (
<div className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Snippets</span>
<span className="font-semibold">{stats.snippetCount}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Templates</span>
<span className="font-semibold">{stats.templateCount}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Storage Type</span>
<span className="font-semibold capitalize">{stats.storageType}</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-sm text-muted-foreground">Database Size</span>
<span className="font-semibold">{formatBytes(stats.databaseSize)}</span>
</div>
</div>
) : (
<p className="text-destructive">Failed to load statistics</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -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<void>
onCheckSchema: () => Promise<void>
}
export function SchemaHealthCard({
schemaHealth,
checkingSchema,
onClear,
onCheckSchema
}: SchemaHealthCardProps) {
if (schemaHealth === 'unknown') return null
if (schemaHealth === 'corrupted') {
return (
<Card className="border-destructive bg-destructive/10">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<Warning weight="fill" size={24} />
Schema Corruption Detected
</CardTitle>
<CardDescription>
Your database schema is outdated or corrupted and needs to be repaired
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert className="border-destructive">
<AlertDescription>
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.
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button onClick={onClear} variant="destructive" className="gap-2">
<FirstAid weight="bold" size={16} />
Repair Database (Wipe & Recreate)
</Button>
<Button onClick={onCheckSchema} variant="outline" disabled={checkingSchema}>
{checkingSchema ? 'Checking...' : 'Re-check Schema'}
</Button>
</div>
</CardContent>
</Card>
)
}
return (
<Card className="border-green-600 bg-green-600/10">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-600">
<CheckCircle weight="fill" size={24} />
Schema Healthy
</CardTitle>
<CardDescription>
Your database schema is up to date and functioning correctly
</CardDescription>
</CardHeader>
</Card>
)
}

View File

@@ -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<void>
onSaveConfig: () => Promise<void>
onMigrateToFlask: () => Promise<void>
onMigrateToIndexedDB: () => Promise<void>
}
export function StorageBackendCard({
storageBackend,
flaskUrl,
flaskConnectionStatus,
testingConnection,
envVarSet,
onStorageBackendChange,
onFlaskUrlChange,
onTestConnection,
onSaveConfig,
onMigrateToFlask,
onMigrateToIndexedDB,
}: StorageBackendCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CloudArrowUp weight="duotone" size={24} />
Storage Backend
</CardTitle>
<CardDescription>
Choose where your snippets are stored
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{envVarSet && (
<Alert className="border-accent bg-accent/10">
<AlertDescription className="flex items-center gap-2">
<CloudCheck weight="fill" size={16} className="text-accent" />
<span>
Storage backend is configured via <code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono">VITE_FLASK_BACKEND_URL</code> environment variable and cannot be changed here.
</span>
</AlertDescription>
</Alert>
)}
<RadioGroup
value={storageBackend}
onValueChange={(value) => onStorageBackendChange(value as StorageBackend)}
disabled={envVarSet}
>
<div className="flex items-start space-x-3 space-y-0">
<RadioGroupItem value="indexeddb" id="storage-indexeddb" disabled={envVarSet} />
<div className="flex-1">
<Label htmlFor="storage-indexeddb" className={`font-semibold ${envVarSet ? 'opacity-50' : 'cursor-pointer'}`}>
IndexedDB (Local Browser Storage)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Store snippets locally in your browser. Data persists on this device only.
</p>
</div>
</div>
<div className="flex items-start space-x-3 space-y-0 mt-4">
<RadioGroupItem value="flask" id="storage-flask" disabled={envVarSet} />
<div className="flex-1">
<Label htmlFor="storage-flask" className={`font-semibold ${envVarSet ? 'opacity-50' : 'cursor-pointer'}`}>
Flask Backend (Remote Server)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Store snippets on a Flask backend server. Data is accessible from any device.
</p>
</div>
</div>
</RadioGroup>
{storageBackend === 'flask' && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-muted/50">
<div>
<Label htmlFor="flask-url">Flask Backend URL</Label>
<div className="flex gap-2 mt-2">
<Input
id="flask-url"
type="url"
placeholder="http://localhost:5000"
value={flaskUrl}
onChange={(e) => onFlaskUrlChange(e.target.value)}
disabled={envVarSet}
/>
<Button
onClick={onTestConnection}
variant="outline"
disabled={testingConnection || !flaskUrl}
>
{testingConnection ? 'Testing...' : 'Test'}
</Button>
</div>
{flaskConnectionStatus === 'connected' && (
<div className="flex items-center gap-2 mt-2 text-sm text-green-600">
<CloudCheck weight="fill" size={16} />
Connected successfully
</div>
)}
{flaskConnectionStatus === 'failed' && (
<div className="flex items-center gap-2 mt-2 text-sm text-destructive">
<CloudSlash weight="fill" size={16} />
Connection failed
</div>
)}
</div>
<div className="pt-2 space-y-2">
<Button
onClick={onMigrateToFlask}
variant="outline"
size="sm"
className="w-full gap-2"
>
<Upload weight="bold" size={16} />
Migrate IndexedDB Data to Flask
</Button>
<Button
onClick={onMigrateToIndexedDB}
variant="outline"
size="sm"
className="w-full gap-2"
>
<Download weight="bold" size={16} />
Migrate Flask Data to IndexedDB
</Button>
</div>
</div>
)}
<div className="pt-2">
<Button onClick={onSaveConfig} className="gap-2" disabled={envVarSet}>
<Database weight="bold" size={16} />
Save Storage Settings
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Storage Information</CardTitle>
<CardDescription>
How your data is stored
</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<AlertDescription>
{storageType === 'indexeddb' ? (
<>
<strong>IndexedDB</strong> 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' ? (
<>
<strong>localStorage</strong> 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.
</>
)}
</AlertDescription>
</Alert>
</CardContent>
</Card>
)
}

View File

@@ -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<SidebarContextProps | null>(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<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}

View File

@@ -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 (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
export function SidebarTrigger({
className,
onClick,
...props
}: ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
export function SidebarRail({ className, ...props }: ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
export function SidebarInset({ className, ...props }: ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}

View File

@@ -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 (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>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 (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>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 (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
export function SidebarMenu({ className, ...props }: ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
export function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
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<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
export function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>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 (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"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",
className
)}
{...props}
/>
)
}
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 (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as CSSProperties
}
/>
</div>
)
}
export function SidebarMenuSub({ className, ...props }: ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export function SidebarMenuSubItem({
className,
...props
}: ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
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 (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>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}
/>
)
}

View File

@@ -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<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
export function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
export function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
export function SidebarSeparator({
className,
...props
}: ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
export function SidebarContent({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
export function SidebarGroup({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}

View File

@@ -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<SidebarContextProps | null>(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<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
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 (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>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 (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>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 (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
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<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>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 (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"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",
className
)}
{...props}
/>
)
}
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 (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
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 (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>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"

View File

@@ -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() {
<div className="grid gap-6 max-w-3xl">
<PersistenceSettings />
{schemaHealth === 'corrupted' && (
<Card className="border-destructive bg-destructive/10">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<Warning weight="fill" size={24} />
Schema Corruption Detected
</CardTitle>
<CardDescription>
Your database schema is outdated or corrupted and needs to be repaired
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert className="border-destructive">
<AlertDescription>
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.
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button onClick={handleClear} variant="destructive" className="gap-2">
<FirstAid weight="bold" size={16} />
Repair Database (Wipe & Recreate)
</Button>
<Button onClick={checkSchemaHealth} variant="outline" disabled={checkingSchema}>
{checkingSchema ? 'Checking...' : 'Re-check Schema'}
</Button>
</div>
</CardContent>
</Card>
)}
<SchemaHealthCard
schemaHealth={schemaHealth}
checkingSchema={checkingSchema}
onClear={handleClear}
onCheckSchema={checkSchemaHealth}
/>
{schemaHealth === 'healthy' && (
<Card className="border-green-600 bg-green-600/10">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-600">
<CheckCircle weight="fill" size={24} />
Schema Healthy
</CardTitle>
<CardDescription>
Your database schema is up to date and functioning correctly
</CardDescription>
</CardHeader>
</Card>
)}
{envVarSet && (
<Card className="border-accent">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-accent">
<CloudCheck weight="fill" size={24} />
Backend Auto-Configured
</CardTitle>
<CardDescription>
Flask backend is configured via environment variable
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">Backend URL</span>
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">{flaskUrl}</code>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">Configuration Source</span>
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">VITE_FLASK_BACKEND_URL</code>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">Status</span>
{flaskConnectionStatus === 'connected' && (
<span className="flex items-center gap-2 text-sm text-green-600">
<CloudCheck weight="fill" size={16} />
Connected
</span>
)}
{flaskConnectionStatus === 'failed' && (
<span className="flex items-center gap-2 text-sm text-destructive">
<CloudSlash weight="fill" size={16} />
Connection Failed
</span>
)}
{flaskConnectionStatus === 'unknown' && (
<Button
onClick={handleTestConnection}
variant="outline"
size="sm"
disabled={testingConnection}
>
{testingConnection ? 'Testing...' : 'Test Connection'}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)}
<BackendAutoConfigCard
envVarSet={envVarSet}
flaskUrl={flaskUrl}
flaskConnectionStatus={flaskConnectionStatus}
testingConnection={testingConnection}
onTestConnection={handleTestConnection}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CloudArrowUp weight="duotone" size={24} />
Storage Backend
</CardTitle>
<CardDescription>
Choose where your snippets are stored
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{envVarSet && (
<Alert className="border-accent bg-accent/10">
<AlertDescription className="flex items-center gap-2">
<CloudCheck weight="fill" size={16} className="text-accent" />
<span>
Storage backend is configured via <code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono">VITE_FLASK_BACKEND_URL</code> environment variable and cannot be changed here.
</span>
</AlertDescription>
</Alert>
)}
<RadioGroup
value={storageBackend}
onValueChange={(value) => setStorageBackend(value as StorageBackend)}
disabled={envVarSet}
>
<div className="flex items-start space-x-3 space-y-0">
<RadioGroupItem value="indexeddb" id="storage-indexeddb" disabled={envVarSet} />
<div className="flex-1">
<Label htmlFor="storage-indexeddb" className={`font-semibold ${envVarSet ? 'opacity-50' : 'cursor-pointer'}`}>
IndexedDB (Local Browser Storage)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Store snippets locally in your browser. Data persists on this device only.
</p>
</div>
</div>
<div className="flex items-start space-x-3 space-y-0 mt-4">
<RadioGroupItem value="flask" id="storage-flask" disabled={envVarSet} />
<div className="flex-1">
<Label htmlFor="storage-flask" className={`font-semibold ${envVarSet ? 'opacity-50' : 'cursor-pointer'}`}>
Flask Backend (Remote Server)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Store snippets on a Flask backend server. Data is accessible from any device.
</p>
</div>
</div>
</RadioGroup>
<StorageBackendCard
storageBackend={storageBackend}
flaskUrl={flaskUrl}
flaskConnectionStatus={flaskConnectionStatus}
testingConnection={testingConnection}
envVarSet={envVarSet}
onStorageBackendChange={setStorageBackend}
onFlaskUrlChange={(url) => {
setFlaskUrl(url)
setFlaskConnectionStatus('unknown')
}}
onTestConnection={handleTestConnection}
onSaveConfig={handleSaveStorageConfig}
onMigrateToFlask={handleMigrateToFlask}
onMigrateToIndexedDB={handleMigrateToIndexedDB}
/>
{storageBackend === 'flask' && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-muted/50">
<div>
<Label htmlFor="flask-url">Flask Backend URL</Label>
<div className="flex gap-2 mt-2">
<Input
id="flask-url"
type="url"
placeholder="http://localhost:5000"
value={flaskUrl}
onChange={(e) => {
setFlaskUrl(e.target.value)
setFlaskConnectionStatus('unknown')
}}
disabled={envVarSet}
/>
<Button
onClick={handleTestConnection}
variant="outline"
disabled={testingConnection || !flaskUrl}
>
{testingConnection ? 'Testing...' : 'Test'}
</Button>
</div>
{flaskConnectionStatus === 'connected' && (
<div className="flex items-center gap-2 mt-2 text-sm text-green-600">
<CloudCheck weight="fill" size={16} />
Connected successfully
</div>
)}
{flaskConnectionStatus === 'failed' && (
<div className="flex items-center gap-2 mt-2 text-sm text-destructive">
<CloudSlash weight="fill" size={16} />
Connection failed
</div>
)}
</div>
<DatabaseStatsCard
loading={loading}
stats={stats}
formatBytes={formatBytes}
/>
<div className="pt-2 space-y-2">
<Button
onClick={handleMigrateToFlask}
variant="outline"
size="sm"
className="w-full gap-2"
>
<Upload weight="bold" size={16} />
Migrate IndexedDB Data to Flask
</Button>
<Button
onClick={handleMigrateToIndexedDB}
variant="outline"
size="sm"
className="w-full gap-2"
>
<Download weight="bold" size={16} />
Migrate Flask Data to IndexedDB
</Button>
</div>
</div>
)}
<StorageInfoCard storageType={stats?.storageType} />
<div className="pt-2">
<Button onClick={handleSaveStorageConfig} className="gap-2" disabled={envVarSet}>
<Database weight="bold" size={16} />
Save Storage Settings
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database weight="duotone" size={24} />
Database Statistics
</CardTitle>
<CardDescription>
Information about your local database storage
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-muted-foreground">Loading...</p>
) : stats ? (
<div className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Snippets</span>
<span className="font-semibold">{stats.snippetCount}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Templates</span>
<span className="font-semibold">{stats.templateCount}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Storage Type</span>
<span className="font-semibold capitalize">{stats.storageType}</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-sm text-muted-foreground">Database Size</span>
<span className="font-semibold">{formatBytes(stats.databaseSize)}</span>
</div>
</div>
) : (
<p className="text-destructive">Failed to load statistics</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Storage Information</CardTitle>
<CardDescription>
How your data is stored
</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<AlertDescription>
{stats?.storageType === 'indexeddb' ? (
<>
<strong>IndexedDB</strong> 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' ? (
<>
<strong>localStorage</strong> 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.
</>
)}
</AlertDescription>
</Alert>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Database Actions</CardTitle>
<CardDescription>
Backup, restore, or reset your database
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-2">Export Database</h3>
<p className="text-sm text-muted-foreground mb-3">
Download your database as a file for backup or transfer to another device
</p>
<Button onClick={handleExport} variant="outline" className="gap-2">
<Download weight="bold" size={16} />
Export Database
</Button>
</div>
<div className="pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2">Import Database</h3>
<p className="text-sm text-muted-foreground mb-3">
Restore a previously exported database file
</p>
<label>
<input
type="file"
accept=".db"
onChange={handleImport}
className="hidden"
id="import-db"
/>
<Button variant="outline" className="gap-2" asChild>
<span>
<Upload weight="bold" size={16} />
Import Database
</span>
</Button>
</label>
</div>
<div className="pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2">Sample Data</h3>
<p className="text-sm text-muted-foreground mb-3">
Add sample code snippets to get started (only if database is empty)
</p>
<Button onClick={handleSeed} variant="outline" className="gap-2">
<Database weight="bold" size={16} />
Add Sample Data
</Button>
</div>
<div className="pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2 text-destructive">Clear All Data</h3>
<p className="text-sm text-muted-foreground mb-3">
Permanently delete all snippets and templates. This cannot be undone.
</p>
<Button onClick={handleClear} variant="destructive" className="gap-2">
<Trash weight="bold" size={16} />
Clear Database
</Button>
</div>
</CardContent>
</Card>
<DatabaseActionsCard
onExport={handleExport}
onImport={handleImport}
onSeed={handleSeed}
onClear={handleClear}
/>
</div>
</motion.div>
)