mirror of
https://github.com/johndoe6345789/snippet-pastebin.git
synced 2026-04-24 13:34:55 +00:00
Generated by Spark: implement program
This commit is contained in:
131
PRD.md
Normal file
131
PRD.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Planning Guide
|
||||
|
||||
A developer-focused code snippet manager that allows users to save, organize, search, and quickly access reusable code snippets across multiple programming languages.
|
||||
|
||||
**Experience Qualities**:
|
||||
1. **Efficient** - Users should be able to save and retrieve snippets in seconds with minimal friction
|
||||
2. **Professional** - The interface should feel polished and trustworthy, suitable for daily developer workflow
|
||||
3. **Intuitive** - Navigation and organization should be self-evident without requiring documentation
|
||||
|
||||
**Complexity Level**: Light Application (multiple features with basic state)
|
||||
This is a CRUD application with search, filtering, and organization features but doesn't require complex routing or advanced state management beyond persisted storage.
|
||||
|
||||
## 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
|
||||
- **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
|
||||
|
||||
### 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
|
||||
|
||||
### Search & Filter
|
||||
- **Functionality**: Real-time search across snippet titles, descriptions, and code content; filter by programming language
|
||||
- **Purpose**: Quick retrieval when snippet library grows large
|
||||
- **Trigger**: Type in search bar or select language filter
|
||||
- **Progression**: Type search query → Results filter in real-time → Clear search to return to full list
|
||||
- **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
|
||||
|
||||
### Copy to Clipboard
|
||||
- **Functionality**: One-click copy of code content to clipboard
|
||||
- **Purpose**: Primary use case - quickly use snippets in other projects
|
||||
- **Trigger**: Click copy icon on snippet card
|
||||
- **Progression**: Click copy button → Visual feedback (toast notification) → Paste code elsewhere
|
||||
- **Success criteria**: Code copies exactly as stored, toast confirms action, works across browsers
|
||||
|
||||
## Edge Case Handling
|
||||
- **Empty State**: Show welcoming illustration and "Create your first snippet" CTA when no snippets exist
|
||||
- **Long Code Blocks**: Truncate preview with "Show more" expansion, scroll within card for full view
|
||||
- **Duplicate Titles**: Allow duplicates but show language badge to differentiate
|
||||
- **Invalid Input**: Require title and code content minimum, show inline validation errors
|
||||
- **Search No Results**: Display "No snippets found" message with suggestion to adjust filters
|
||||
- **Network/Storage Errors**: Graceful error messages with retry options (though KV storage is local)
|
||||
|
||||
## Design Direction
|
||||
The design should evoke a premium developer tool - clean, focused, and sophisticated with subtle tech-forward aesthetics. Think VS Code meets Notion: professional minimalism with purposeful color accents and smooth micro-interactions that make frequent use satisfying.
|
||||
|
||||
## Color Selection
|
||||
A developer-focused dark-leaning palette with vibrant accent colors for actions and syntax language tags.
|
||||
|
||||
- **Primary Color**: Deep indigo `oklch(0.35 0.15 265)` - Communicates technical sophistication and trust, used for primary actions and headers
|
||||
- **Secondary Colors**: Charcoal gray `oklch(0.25 0.01 265)` for cards/surfaces; Soft gray `oklch(0.65 0.01 265)` for secondary text
|
||||
- **Accent Color**: Electric cyan `oklch(0.75 0.15 195)` - Attention-grabbing highlight for CTAs, copy actions, and success states
|
||||
- **Foreground/Background Pairings**:
|
||||
- Background (Off-black #0F0F14 / oklch(0.08 0.01 265)): Light gray text (oklch(0.95 0.01 265) #F0F0F2) - Ratio 11.2:1 ✓
|
||||
- Card surface (oklch(0.15 0.01 265)): White text (oklch(0.98 0 0)) - Ratio 13.5:1 ✓
|
||||
- Primary (Deep indigo oklch(0.35 0.15 265)): White text (oklch(0.98 0 0)) - Ratio 7.8:1 ✓
|
||||
- Accent (Cyan oklch(0.75 0.15 195)): Deep gray text (oklch(0.15 0.01 265)) - Ratio 8.5:1 ✓
|
||||
|
||||
## Font Selection
|
||||
Typography should balance technical precision with readability - a clean geometric sans for UI elements and a monospace font for code display that developers recognize and trust.
|
||||
|
||||
- **Typographic Hierarchy**:
|
||||
- H1 (App Title): Space Grotesk Bold/32px/tight letter spacing (-0.02em)
|
||||
- H2 (Section Headers): Space Grotesk SemiBold/20px/normal spacing
|
||||
- Body (UI Text): Space Grotesk Regular/15px/1.5 line height
|
||||
- Code Display: JetBrains Mono Regular/14px/1.6 line height
|
||||
- Labels & Captions: Space Grotesk Medium/13px/uppercase with increased tracking (0.05em)
|
||||
|
||||
## Animations
|
||||
Animations should feel responsive and technical - quick, purposeful movements that provide feedback without delay. Focus on micro-interactions: button states that respond instantly, smooth card expansions when viewing details, and satisfying confirmation animations when copying code (scale pulse + checkmark). Keep transitions under 200ms for interactions, 300ms max for layout changes. Use ease-out curves for most interactions to feel snappy.
|
||||
|
||||
## Component Selection
|
||||
- **Components**:
|
||||
- Dialog for create/edit snippet forms with full-screen modal feel on mobile
|
||||
- Card for snippet list items with hover elevation and click interaction
|
||||
- Input for search bar with clear button and icon
|
||||
- Button for all actions (primary solid style for Save/Create, ghost style for secondary actions)
|
||||
- Select for language dropdown with custom styling to match theme
|
||||
- Textarea for code input with monospace font
|
||||
- Badge for language tags with color coding
|
||||
- ScrollArea for long code blocks within cards
|
||||
- Toast (Sonner) for copy confirmations and success messages
|
||||
- AlertDialog for delete confirmations
|
||||
|
||||
- **Customizations**:
|
||||
- Custom syntax language badges with predefined color mapping (JavaScript=yellow, Python=blue, etc.)
|
||||
- Floating action button for "New Snippet" with plus icon, fixed bottom-right on mobile
|
||||
- Custom empty state component with illustration or icon and encouraging copy
|
||||
|
||||
- **States**:
|
||||
- Buttons: Default with subtle gradient, hover with brightness increase and shadow, active with slight scale-down (0.98), disabled with 50% opacity
|
||||
- Cards: Default flat, hover with shadow-lg and border glow, selected/expanded with accent border
|
||||
- Inputs: Default with muted border, focus with accent ring and border color shift, error with red ring
|
||||
|
||||
- **Icon Selection**:
|
||||
- Plus (create new snippet)
|
||||
- Copy (clipboard action)
|
||||
- Pencil (edit action)
|
||||
- Trash (delete action)
|
||||
- MagnifyingGlass (search)
|
||||
- Code (app logo/branding)
|
||||
- Check (confirmation feedback)
|
||||
|
||||
- **Spacing**:
|
||||
- Container padding: p-6 (desktop) / p-4 (mobile)
|
||||
- Card gaps: gap-4 for grid layout
|
||||
- Form fields: space-y-4 for vertical stacking
|
||||
- Section margins: mb-8 for major sections
|
||||
- Inline elements: gap-2 for icon+text combinations
|
||||
|
||||
- **Mobile**:
|
||||
- Single column card layout with full-width cards
|
||||
- Dialog becomes full-screen sheet on mobile
|
||||
- Search bar remains sticky at top
|
||||
- Floating action button (FAB) for create action instead of header button
|
||||
- Touch-friendly button sizes (min 44px tap targets)
|
||||
- Bottom sheet for language filter on mobile vs. dropdown on desktop
|
||||
@@ -4,9 +4,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
<title>SnippetVault - Code Snippet Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/src/main.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"templateVersion": 1
|
||||
}
|
||||
"templateVersion": 1,
|
||||
"dbType": "kv"
|
||||
}
|
||||
233
src/App.tsx
233
src/App.tsx
@@ -1,5 +1,236 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useKV } from '@github/spark/hooks'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Code, Plus, MagnifyingGlass, Funnel } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import { SnippetCard } from '@/components/SnippetCard'
|
||||
import { SnippetDialog } from '@/components/SnippetDialog'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Snippet, LANGUAGES } from '@/lib/types'
|
||||
|
||||
function App() {
|
||||
return <div></div>
|
||||
const [snippets, setSnippets] = useKV<Snippet[]>('snippets', [])
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [filterLanguage, setFilterLanguage] = useState<string>('all')
|
||||
|
||||
const filteredSnippets = useMemo(() => {
|
||||
let filtered = snippets || []
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(snippet) =>
|
||||
snippet.title.toLowerCase().includes(query) ||
|
||||
snippet.description.toLowerCase().includes(query) ||
|
||||
snippet.code.toLowerCase().includes(query) ||
|
||||
snippet.language.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterLanguage !== 'all') {
|
||||
filtered = filtered.filter((snippet) => snippet.language === filterLanguage)
|
||||
}
|
||||
|
||||
return filtered.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}, [snippets, searchQuery, filterLanguage])
|
||||
|
||||
const handleSave = (snippetData: Omit<Snippet, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
if (editingSnippet) {
|
||||
setSnippets((current) =>
|
||||
(current || []).map((s) =>
|
||||
s.id === editingSnippet.id
|
||||
? { ...s, ...snippetData, updatedAt: Date.now() }
|
||||
: s
|
||||
)
|
||||
)
|
||||
toast.success('Snippet updated successfully')
|
||||
} else {
|
||||
const newSnippet: Snippet = {
|
||||
...snippetData,
|
||||
id: Date.now().toString(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
setSnippets((current) => [newSnippet, ...(current || [])])
|
||||
toast.success('Snippet created successfully')
|
||||
}
|
||||
setEditingSnippet(null)
|
||||
}
|
||||
|
||||
const handleEdit = (snippet: Snippet) => {
|
||||
setEditingSnippet(snippet)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDeleteId(id)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteId) {
|
||||
setSnippets((current) => (current || []).filter((s) => s.id !== deleteId))
|
||||
toast.success('Snippet deleted')
|
||||
setDeleteId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (code: string) => {
|
||||
navigator.clipboard.writeText(code)
|
||||
toast.success('Code copied to clipboard')
|
||||
}
|
||||
|
||||
const handleNewSnippet = () => {
|
||||
setEditingSnippet(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-accent/10 p-2">
|
||||
<Code className="h-7 w-7 text-accent" weight="bold" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||
SnippetVault
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground hidden sm:block">
|
||||
Your personal code snippet library
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleNewSnippet} className="gap-2 hidden sm:flex">
|
||||
<Plus className="h-5 w-5" weight="bold" />
|
||||
New Snippet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search snippets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 sm:flex-none sm:w-48">
|
||||
<Funnel className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground pointer-events-none" />
|
||||
<Select value={filterLanguage} onValueChange={setFilterLanguage}>
|
||||
<SelectTrigger className="pl-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Languages</SelectItem>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang} value={lang}>
|
||||
{lang}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{!snippets || snippets.length === 0 ? (
|
||||
<EmptyState onCreateClick={handleNewSnippet} />
|
||||
) : filteredSnippets.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
No snippets match your search.
|
||||
</p>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setFilterLanguage('all')
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredSnippets.map((snippet) => (
|
||||
<SnippetCard
|
||||
key={snippet.id}
|
||||
snippet={snippet}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Button
|
||||
onClick={handleNewSnippet}
|
||||
size="lg"
|
||||
className="fixed bottom-6 right-6 h-14 w-14 rounded-full shadow-lg sm:hidden"
|
||||
>
|
||||
<Plus className="h-6 w-6" weight="bold" />
|
||||
</Button>
|
||||
|
||||
<SnippetDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open)
|
||||
if (!open) setEditingSnippet(null)
|
||||
}}
|
||||
onSave={handleSave}
|
||||
editingSnippet={editingSnippet}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete snippet?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete this code snippet.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
24
src/components/EmptyState.tsx
Normal file
24
src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Code } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface EmptyStateProps {
|
||||
onCreateClick: () => void
|
||||
}
|
||||
|
||||
export function EmptyState({ onCreateClick }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 px-4 text-center">
|
||||
<div className="rounded-full bg-accent/10 p-6 mb-6">
|
||||
<Code className="h-16 w-16 text-accent" weight="duotone" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-2">No snippets yet</h3>
|
||||
<p className="text-muted-foreground mb-8 max-w-sm">
|
||||
Start building your code snippet library. Save reusable code for quick access anytime.
|
||||
</p>
|
||||
<Button onClick={onCreateClick} size="lg" className="gap-2">
|
||||
<Code className="h-5 w-5" weight="bold" />
|
||||
Create Your First Snippet
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
src/components/SnippetCard.tsx
Normal file
120
src/components/SnippetCard.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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 { 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
|
||||
}
|
||||
|
||||
export function SnippetCard({ snippet, onEdit, onDelete, onCopy }: SnippetCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
onCopy(snippet.code)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
}
|
||||
|
||||
const truncatedCode = snippet.code.length > 200
|
||||
? snippet.code.slice(0, 200) + '...'
|
||||
: snippet.code
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"group relative overflow-hidden transition-all duration-200",
|
||||
"hover:shadow-lg hover:shadow-accent/10 hover:border-accent/30",
|
||||
"cursor-pointer"
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg text-foreground truncate mb-1">
|
||||
{snippet.title}
|
||||
</h3>
|
||||
{snippet.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="flex items-center justify-between pt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(snippet.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 w-8 p-0 hover:bg-accent/20 hover:text-accent"
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-accent" weight="bold" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(snippet)}
|
||||
className="h-8 w-8 p-0 hover:bg-primary/20 hover:text-primary"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(snippet.id)}
|
||||
className="h-8 w-8 p-0 hover:bg-destructive/20 hover:text-destructive"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
165
src/components/SnippetDialog.tsx
Normal file
165
src/components/SnippetDialog.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Snippet, LANGUAGES } from '@/lib/types'
|
||||
|
||||
interface SnippetDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (snippet: Omit<Snippet, 'id' | 'createdAt' | 'updatedAt'>) => void
|
||||
editingSnippet?: Snippet | null
|
||||
}
|
||||
|
||||
export function SnippetDialog({ open, onOpenChange, onSave, editingSnippet }: SnippetDialogProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [language, setLanguage] = useState('JavaScript')
|
||||
const [code, setCode] = useState('')
|
||||
const [errors, setErrors] = useState<{ title?: string; code?: string }>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (editingSnippet) {
|
||||
setTitle(editingSnippet.title)
|
||||
setDescription(editingSnippet.description)
|
||||
setLanguage(editingSnippet.language)
|
||||
setCode(editingSnippet.code)
|
||||
} else {
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setLanguage('JavaScript')
|
||||
setCode('')
|
||||
}
|
||||
setErrors({})
|
||||
}, [editingSnippet, open])
|
||||
|
||||
const handleSave = () => {
|
||||
const newErrors: { title?: string; code?: string } = {}
|
||||
|
||||
if (!title.trim()) {
|
||||
newErrors.title = 'Title is required'
|
||||
}
|
||||
if (!code.trim()) {
|
||||
newErrors.code = 'Code is required'
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors)
|
||||
return
|
||||
}
|
||||
|
||||
onSave({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
language,
|
||||
code: code.trim(),
|
||||
})
|
||||
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setLanguage('JavaScript')
|
||||
setCode('')
|
||||
setErrors({})
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">
|
||||
{editingSnippet ? 'Edit Snippet' : 'Create New Snippet'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingSnippet
|
||||
? 'Update your code snippet details below.'
|
||||
: 'Save a reusable code snippet for quick access later.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="e.g., React useState Hook"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className={errors.title ? 'border-destructive ring-destructive' : ''}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-sm text-destructive">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language">Language</Label>
|
||||
<Select value={language} onValueChange={setLanguage}>
|
||||
<SelectTrigger id="language">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang} value={lang}>
|
||||
{lang}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Optional description or notes..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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' : ''}`}
|
||||
/>
|
||||
{errors.code && (
|
||||
<p className="text-sm text-destructive">{errors.code}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{editingSnippet ? 'Update' : 'Create'} Snippet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1 +1,7 @@
|
||||
/* This is where custom CSS goes */
|
||||
* {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
}
|
||||
|
||||
code, pre, textarea.font-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
51
src/lib/types.ts
Normal file
51
src/lib/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface Snippet {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
language: string
|
||||
code: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export const LANGUAGES = [
|
||||
'JavaScript',
|
||||
'TypeScript',
|
||||
'Python',
|
||||
'Java',
|
||||
'C++',
|
||||
'C#',
|
||||
'Ruby',
|
||||
'Go',
|
||||
'Rust',
|
||||
'PHP',
|
||||
'Swift',
|
||||
'Kotlin',
|
||||
'HTML',
|
||||
'CSS',
|
||||
'SQL',
|
||||
'Bash',
|
||||
'Other'
|
||||
] as const
|
||||
|
||||
export type Language = typeof LANGUAGES[number]
|
||||
|
||||
export const LANGUAGE_COLORS: Record<string, string> = {
|
||||
'JavaScript': 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30',
|
||||
'TypeScript': 'bg-blue-500/20 text-blue-300 border-blue-500/30',
|
||||
'Python': 'bg-blue-400/20 text-blue-200 border-blue-400/30',
|
||||
'Java': 'bg-red-500/20 text-red-300 border-red-500/30',
|
||||
'C++': 'bg-pink-500/20 text-pink-300 border-pink-500/30',
|
||||
'C#': 'bg-purple-500/20 text-purple-300 border-purple-500/30',
|
||||
'Ruby': 'bg-red-600/20 text-red-300 border-red-600/30',
|
||||
'Go': 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30',
|
||||
'Rust': 'bg-orange-600/20 text-orange-300 border-orange-600/30',
|
||||
'PHP': 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30',
|
||||
'Swift': 'bg-orange-500/20 text-orange-300 border-orange-500/30',
|
||||
'Kotlin': 'bg-purple-600/20 text-purple-300 border-purple-600/30',
|
||||
'HTML': 'bg-orange-400/20 text-orange-300 border-orange-400/30',
|
||||
'CSS': 'bg-blue-600/20 text-blue-300 border-blue-600/30',
|
||||
'SQL': 'bg-teal-500/20 text-teal-300 border-teal-500/30',
|
||||
'Bash': 'bg-green-500/20 text-green-300 border-green-500/30',
|
||||
'Other': 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||
}
|
||||
53
src/main.css
53
src/main.css
@@ -32,38 +32,39 @@
|
||||
---break---
|
||||
*/
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(0.08 0.01 265);
|
||||
--foreground: oklch(0.95 0.01 265);
|
||||
--card: oklch(0.15 0.01 265);
|
||||
--card-foreground: oklch(0.98 0 0);
|
||||
--popover: oklch(0.15 0.01 265);
|
||||
--popover-foreground: oklch(0.98 0 0);
|
||||
--primary: oklch(0.35 0.15 265);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
--secondary: oklch(0.25 0.01 265);
|
||||
--secondary-foreground: oklch(0.95 0.01 265);
|
||||
--muted: oklch(0.20 0.01 265);
|
||||
--muted-foreground: oklch(0.65 0.01 265);
|
||||
--accent: oklch(0.75 0.15 195);
|
||||
--accent-foreground: oklch(0.15 0.01 265);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
--border: oklch(0.25 0.01 265);
|
||||
--input: oklch(0.25 0.01 265);
|
||||
--ring: oklch(0.75 0.15 195);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar: oklch(0.15 0.01 265);
|
||||
--sidebar-foreground: oklch(0.98 0 0);
|
||||
--sidebar-primary: oklch(0.35 0.15 265);
|
||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||
--sidebar-accent: oklch(0.25 0.01 265);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.01 265);
|
||||
--sidebar-border: oklch(0.25 0.01 265);
|
||||
--sidebar-ring: oklch(0.75 0.15 195);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import "@github/spark/spark"
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
import App from './App.tsx'
|
||||
import { ErrorFallback } from './ErrorFallback.tsx'
|
||||
@@ -12,5 +13,6 @@ import "./index.css"
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<App />
|
||||
<Toaster />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user