diff --git a/PRD.md b/PRD.md index 3a463cf..831ac02 100644 --- a/PRD.md +++ b/PRD.md @@ -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 diff --git a/package-lock.json b/package-lock.json index c92f18e..e5a1a68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 165aec8..6fac6a5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/spark.meta.json b/spark.meta.json index 1d49b2a..fd74d91 100644 --- a/spark.meta.json +++ b/spark.meta.json @@ -1,3 +1,4 @@ -{ - "dbType": null - +{ + "templateVersion": 0, + "dbType": null +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 386ad5a..49a5d35 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('snippets', []) const [dialogOpen, setDialogOpen] = useState(false) const [editingSnippet, setEditingSnippet] = useState(null) + const [viewingSnippet, setViewingSnippet] = useState(null) const [deleteId, setDeleteId] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [filterLanguage, setFilterLanguage] = useState('all') @@ -105,6 +107,10 @@ function App() { setDialogOpen(true) } + const handleView = (snippet: Snippet) => { + setViewingSnippet(snippet) + } + return (
@@ -189,6 +195,7 @@ function App() { onEdit={handleEdit} onDelete={handleDelete} onCopy={handleCopy} + onView={handleView} /> ))}
@@ -213,6 +220,14 @@ function App() { editingSnippet={editingSnippet} /> + !open && setViewingSnippet(null)} + onEdit={handleEdit} + onCopy={handleCopy} + /> + !open && setDeleteId(null)}> diff --git a/src/components/MonacoEditor.tsx b/src/components/MonacoEditor.tsx new file mode 100644 index 0000000..78147bf --- /dev/null +++ b/src/components/MonacoEditor.tsx @@ -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 ( +
+ +
+ ) +} + +export function MonacoEditor({ + value, + onChange, + language, + height = '400px', + readOnly = false +}: MonacoEditorProps) { + const monacoLanguage = getMonacoLanguage(language) + + return ( + }> + 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, + }} + /> + + ) +} + +function getMonacoLanguage(language: string): string { + const languageMap: Record = { + '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' +} diff --git a/src/components/SnippetCard.tsx b/src/components/SnippetCard.tsx index afc853a..dd73d99 100644 --- a/src/components/SnippetCard.tsx +++ b/src/components/SnippetCard.tsx @@ -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 setIsExpanded(!isExpanded)} >
@@ -60,26 +57,29 @@ export function SnippetCard({ snippet, onEdit, onDelete, onCopy }: SnippetCardPr
-
- {isExpanded ? ( - -
-                {snippet.code}
-              
-
- ) : ( -
-
-                {truncatedCode}
-              
- {snippet.code.length > 200 && ( -
- )} -
+
onView(snippet)} + > +
+            {truncatedCode}
+          
+ {snippet.code.length > 200 && ( +
)} +
+ +
-
e.stopPropagation()}> +
{new Date(snippet.updatedAt).toLocaleDateString()} diff --git a/src/components/SnippetDialog.tsx b/src/components/SnippetDialog.tsx index e3e86a6..43832a8 100644 --- a/src/components/SnippetDialog.tsx +++ b/src/components/SnippetDialog.tsx @@ -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 ( - + {editingSnippet ? 'Edit Snippet' : 'Create New Snippet'} @@ -93,7 +94,7 @@ export function SnippetDialog({ open, onOpenChange, onSave, editingSnippet }: Sn -
+
-