Generated by Spark: Implement monaco editor with lazy loading

This commit is contained in:
2026-01-17 14:39:28 +00:00
committed by GitHub
parent 0efee389d3
commit ea56ab7754
9 changed files with 348 additions and 51 deletions

28
PRD.md
View File

@@ -13,18 +13,18 @@ This is a CRUD application with search, filtering, and organization features but
## Essential Features
### Create Snippet
- **Functionality**: Users can create a new code snippet with title, description, language selection, and code content
- **Purpose**: Core value proposition - storing reusable code for later retrieval
- **Functionality**: Users can create a new code snippet with title, description, language selection, and code content using Monaco Editor for enhanced code editing
- **Purpose**: Core value proposition - storing reusable code for later retrieval with professional IDE-like experience
- **Trigger**: Click "New Snippet" button or keyboard shortcut
- **Progression**: Click New Snippet → Fill in title field → Select language from dropdown → Paste/type code → Add optional description → Click Save
- **Success criteria**: Snippet appears in the list immediately, persists across page refreshes, and is searchable
- **Progression**: Click New Snippet → Fill in title field → Select language from dropdown → Write/paste code in Monaco Editor with syntax highlighting → Add optional description → Click Save
- **Success criteria**: Snippet appears in the list immediately, persists across page refreshes, Monaco Editor loads lazily without blocking UI, and code is searchable
### View & Organize Snippets
- **Functionality**: Display all snippets in a filterable list with preview cards showing title, language, and truncated code
- **Purpose**: Quick scanning and navigation through saved snippets
- **Trigger**: Default view on app load
- **Progression**: View list → Scan titles and languages → Click card to expand/view full details
- **Success criteria**: All snippets visible, sorted by recent first, with clear visual hierarchy
- **Functionality**: Display all snippets in a filterable list with preview cards showing title, language, and truncated code; click to open full-screen Monaco viewer
- **Purpose**: Quick scanning and navigation through saved snippets with professional code viewing
- **Trigger**: Default view on app load, click card to view full code
- **Progression**: View list → Scan titles and languages → Click card to open full-screen Monaco viewer with syntax highlighting → Copy or edit from viewer
- **Success criteria**: All snippets visible, sorted by recent first, viewer opens instantly with lazy-loaded Monaco Editor
### Search & Filter
- **Functionality**: Real-time search across snippet titles, descriptions, and code content; filter by programming language
@@ -34,11 +34,11 @@ This is a CRUD application with search, filtering, and organization features but
- **Success criteria**: Results appear instantly (<100ms), search is case-insensitive, highlights matched terms
### Edit & Delete
- **Functionality**: Modify existing snippets or remove them entirely
- **Purpose**: Keep snippet library current and relevant
- **Trigger**: Click edit icon on snippet card or click delete with confirmation
- **Progression**: Click Edit → Modify fields in modal → Save changes → See updated snippet in list
- **Success criteria**: Changes persist, delete requires confirmation, no accidental data loss
- **Functionality**: Modify existing snippets using Monaco Editor or remove them entirely
- **Purpose**: Keep snippet library current and relevant with professional editing experience
- **Trigger**: Click edit icon on snippet card or from viewer, click delete with confirmation
- **Progression**: Click Edit → Monaco Editor opens with existing code → Modify fields with syntax highlighting → Save changes → See updated snippet in list
- **Success criteria**: Changes persist, Monaco Editor retains user edits, delete requires confirmation, no accidental data loss
### Copy to Clipboard
- **Functionality**: One-click copy of code content to clipboard

72
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@github/spark": ">=0.43.1 <1",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^4.1.3",
"@monaco-editor/react": "^4.7.0",
"@octokit/core": "^6.1.4",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/colors": "^3.0.0",
@@ -1076,6 +1077,29 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -4404,6 +4428,14 @@
"@types/node": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz",
@@ -5992,6 +6024,16 @@
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -7953,6 +7995,30 @@
"node": "*"
}
},
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/monaco-editor/node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
@@ -9198,6 +9264,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View File

@@ -15,6 +15,7 @@
"@github/spark": ">=0.43.1 <1",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^4.1.3",
"@monaco-editor/react": "^4.7.0",
"@octokit/core": "^6.1.4",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/colors": "^3.0.0",

View File

@@ -1,3 +1,4 @@
{
"dbType": null
{
"templateVersion": 0,
"dbType": null
}

View File

@@ -23,6 +23,7 @@ import { Code, Plus, MagnifyingGlass, Funnel } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { SnippetCard } from '@/components/SnippetCard'
import { SnippetDialog } from '@/components/SnippetDialog'
import { SnippetViewer } from '@/components/SnippetViewer'
import { EmptyState } from '@/components/EmptyState'
import { Snippet, LANGUAGES } from '@/lib/types'
@@ -30,6 +31,7 @@ function App() {
const [snippets, setSnippets] = useKV<Snippet[]>('snippets', [])
const [dialogOpen, setDialogOpen] = useState(false)
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null)
const [viewingSnippet, setViewingSnippet] = useState<Snippet | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [filterLanguage, setFilterLanguage] = useState<string>('all')
@@ -105,6 +107,10 @@ function App() {
setDialogOpen(true)
}
const handleView = (snippet: Snippet) => {
setViewingSnippet(snippet)
}
return (
<div className="min-h-screen bg-background">
<div className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-10">
@@ -189,6 +195,7 @@ function App() {
onEdit={handleEdit}
onDelete={handleDelete}
onCopy={handleCopy}
onView={handleView}
/>
))}
</div>
@@ -213,6 +220,14 @@ function App() {
editingSnippet={editingSnippet}
/>
<SnippetViewer
snippet={viewingSnippet}
open={!!viewingSnippet}
onOpenChange={(open) => !open && setViewingSnippet(null)}
onEdit={handleEdit}
onCopy={handleCopy}
/>
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>

View File

@@ -0,0 +1,87 @@
import { lazy, Suspense } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
const Editor = lazy(() => import('@monaco-editor/react'))
interface MonacoEditorProps {
value: string
onChange: (value: string) => void
language: string
height?: string
readOnly?: boolean
}
function EditorLoadingSkeleton({ height = '400px' }: { height?: string }) {
return (
<div className="space-y-2" style={{ height }}>
<Skeleton className="h-full w-full rounded-md" />
</div>
)
}
export function MonacoEditor({
value,
onChange,
language,
height = '400px',
readOnly = false
}: MonacoEditorProps) {
const monacoLanguage = getMonacoLanguage(language)
return (
<Suspense fallback={<EditorLoadingSkeleton height={height} />}>
<Editor
height={height}
language={monacoLanguage}
value={value}
onChange={(newValue) => onChange(newValue || '')}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on',
readOnly,
scrollbar: {
vertical: 'auto',
horizontal: 'auto',
useShadows: false,
},
padding: {
top: 12,
bottom: 12,
},
fontFamily: 'JetBrains Mono, monospace',
fontLigatures: true,
}}
/>
</Suspense>
)
}
function getMonacoLanguage(language: string): string {
const languageMap: Record<string, string> = {
'JavaScript': 'javascript',
'TypeScript': 'typescript',
'Python': 'python',
'Java': 'java',
'C++': 'cpp',
'C#': 'csharp',
'Ruby': 'ruby',
'Go': 'go',
'Rust': 'rust',
'PHP': 'php',
'Swift': 'swift',
'Kotlin': 'kotlin',
'HTML': 'html',
'CSS': 'css',
'SQL': 'sql',
'Bash': 'shell',
'Other': 'plaintext',
}
return languageMap[language] || 'plaintext'
}

View File

@@ -2,20 +2,19 @@ import { useState } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Copy, Pencil, Trash, Check } from '@phosphor-icons/react'
import { Copy, Pencil, Trash, Check, ArrowsOut } from '@phosphor-icons/react'
import { Snippet, LANGUAGE_COLORS } from '@/lib/types'
import { cn } from '@/lib/utils'
import { ScrollArea } from '@/components/ui/scroll-area'
interface SnippetCardProps {
snippet: Snippet
onEdit: (snippet: Snippet) => void
onDelete: (id: string) => void
onCopy: (code: string) => void
onView: (snippet: Snippet) => void
}
export function SnippetCard({ snippet, onEdit, onDelete, onCopy }: SnippetCardProps) {
const [isExpanded, setIsExpanded] = useState(false)
export function SnippetCard({ snippet, onEdit, onDelete, onCopy, onView }: SnippetCardProps) {
const [isCopied, setIsCopied] = useState(false)
const handleCopy = () => {
@@ -32,10 +31,8 @@ export function SnippetCard({ snippet, onEdit, onDelete, onCopy }: SnippetCardPr
<Card
className={cn(
"group relative overflow-hidden transition-all duration-200",
"hover:shadow-lg hover:shadow-accent/10 hover:border-accent/30",
"cursor-pointer"
"hover:shadow-lg hover:shadow-accent/10 hover:border-accent/30"
)}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="p-5 space-y-3">
<div className="flex items-start justify-between gap-3">
@@ -60,26 +57,29 @@ export function SnippetCard({ snippet, onEdit, onDelete, onCopy }: SnippetCardPr
</Badge>
</div>
<div className="relative">
{isExpanded ? (
<ScrollArea className="h-64 w-full rounded-md border border-border bg-secondary/30 p-3">
<pre className="text-sm text-foreground/90">
<code className="font-mono">{snippet.code}</code>
</pre>
</ScrollArea>
) : (
<div className="relative rounded-md border border-border bg-secondary/30 p-3 overflow-hidden">
<pre className="text-sm text-foreground/90 line-clamp-4">
<code className="font-mono">{truncatedCode}</code>
</pre>
{snippet.code.length > 200 && (
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-secondary/30 to-transparent" />
)}
</div>
<div
className="relative rounded-md border border-border bg-secondary/30 p-3 overflow-hidden cursor-pointer hover:border-accent/50 transition-colors"
onClick={() => onView(snippet)}
>
<pre className="text-sm text-foreground/90 line-clamp-6">
<code className="font-mono">{truncatedCode}</code>
</pre>
{snippet.code.length > 200 && (
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-secondary/30 to-transparent" />
)}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="secondary"
size="sm"
className="h-8 gap-2 bg-background/80 backdrop-blur-sm"
>
<ArrowsOut className="h-4 w-4" />
View
</Button>
</div>
</div>
<div className="flex items-center justify-between pt-2" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-muted-foreground">
{new Date(snippet.updatedAt).toLocaleDateString()}
</span>

View File

@@ -19,6 +19,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Snippet, LANGUAGES } from '@/lib/types'
import { MonacoEditor } from '@/components/MonacoEditor'
interface SnippetDialogProps {
open: boolean
@@ -81,7 +82,7 @@ export function SnippetDialog({ open, onOpenChange, onSave, editingSnippet }: Sn
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-2xl">
{editingSnippet ? 'Edit Snippet' : 'Create New Snippet'}
@@ -93,7 +94,7 @@ export function SnippetDialog({ open, onOpenChange, onSave, editingSnippet }: Sn
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 py-4 overflow-y-auto flex-1">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
@@ -137,14 +138,14 @@ export function SnippetDialog({ open, onOpenChange, onSave, editingSnippet }: Sn
<div className="space-y-2">
<Label htmlFor="code">Code *</Label>
<Textarea
id="code"
placeholder="Paste your code here..."
value={code}
onChange={(e) => setCode(e.target.value)}
rows={12}
className={`font-mono text-sm ${errors.code ? 'border-destructive ring-destructive' : ''}`}
/>
<div className={`rounded-md border overflow-hidden ${errors.code ? 'border-destructive ring-2 ring-destructive/20' : 'border-border'}`}>
<MonacoEditor
value={code}
onChange={setCode}
language={language}
height="400px"
/>
</div>
{errors.code && (
<p className="text-sm text-destructive">{errors.code}</p>
)}

View File

@@ -0,0 +1,120 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Copy, Pencil, X, Check } from '@phosphor-icons/react'
import { Snippet, LANGUAGE_COLORS } from '@/lib/types'
import { MonacoEditor } from '@/components/MonacoEditor'
import { cn } from '@/lib/utils'
import { useState } from 'react'
interface SnippetViewerProps {
snippet: Snippet | null
open: boolean
onOpenChange: (open: boolean) => void
onEdit: (snippet: Snippet) => void
onCopy: (code: string) => void
}
export function SnippetViewer({ snippet, open, onOpenChange, onEdit, onCopy }: SnippetViewerProps) {
const [isCopied, setIsCopied] = useState(false)
if (!snippet) return null
const handleCopy = () => {
onCopy(snippet.code)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
}
const handleEdit = () => {
onOpenChange(false)
onEdit(snippet)
}
return (
<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 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">
Last updated: {new Date(snippet.updatedAt).toLocaleString()}
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="gap-2"
>
{isCopied ? (
<>
<Check className="h-4 w-4" weight="bold" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleEdit}
className="gap-2"
>
<Pencil className="h-4 w-4" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
className="h-9 w-9 p-0"
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-hidden">
<MonacoEditor
value={snippet.code}
onChange={() => {}}
language={snippet.language}
height="100%"
readOnly={true}
/>
</div>
</DialogContent>
</Dialog>
)
}