diff --git a/src/components/SnippetCard.tsx b/src/components/SnippetCard.tsx index 2dc337c..b9d5382 100644 --- a/src/components/SnippetCard.tsx +++ b/src/components/SnippetCard.tsx @@ -2,6 +2,7 @@ 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, @@ -25,9 +26,22 @@ interface SnippetCardProps { onCopy: (code: string) => void onView: (snippet: Snippet) => void onMove?: () => void + selectionMode?: boolean + isSelected?: boolean + onToggleSelect?: (id: string) => void } -export function SnippetCard({ snippet, onEdit, onDelete, onCopy, onView, onMove }: SnippetCardProps) { +export function SnippetCard({ + snippet, + onEdit, + onDelete, + onCopy, + onView, + onMove, + selectionMode = false, + isSelected = false, + onToggleSelect +}: SnippetCardProps) { const [isCopied, setIsCopied] = useState(false) const [namespaces, setNamespaces] = useState([]) const [isMoving, setIsMoving] = useState(false) @@ -89,7 +103,17 @@ export function SnippetCard({ snippet, onEdit, onDelete, onCopy, onView, onMove } const handleView = (e: React.MouseEvent) => { - onView(snippet) + if (selectionMode) { + handleToggleSelect() + } else { + onView(snippet) + } + } + + const handleToggleSelect = () => { + if (onToggleSelect) { + onToggleSelect(snippet.id) + } } const handleMoveToNamespace = async (targetNamespaceId: string) => { @@ -127,20 +151,32 @@ export function SnippetCard({ snippet, onEdit, onDelete, onCopy, onView, onMove return (
-
-

- {snippet.title} -

- {snippetData.description && ( -

- {snippetData.description} -

+
+ {selectionMode && ( + e.stopPropagation()} + className="mt-1" + /> )} +
+

+ {snippet.title} +

+ {snippetData.description && ( +

+ {snippetData.description} +

+ )} +
-
-
- -
-
- - - - - - - - e.stopPropagation()}> - - - - Move to... - - - {availableNamespaces.length === 0 ? ( - - No other namespaces - - ) : ( - availableNamespaces.map((namespace) => ( - handleMoveToNamespace(namespace.id)} - > - {namespace.name} - {namespace.isDefault && ( - (Default) - )} + {!selectionMode && ( +
+
+ +
+
+ + + + + + + + e.stopPropagation()}> + + + + Move to... + + + {availableNamespaces.length === 0 ? ( + + No other namespaces - )) - )} - - - - - - Delete - - - + ) : ( + availableNamespaces.map((namespace) => ( + handleMoveToNamespace(namespace.id)} + > + {namespace.name} + {namespace.isDefault && ( + (Default) + )} + + )) + )} + + + + + + Delete + + + +
-
+ )}
) diff --git a/src/components/SnippetManager.tsx b/src/components/SnippetManager.tsx index a9fb0f6..a6c531a 100644 --- a/src/components/SnippetManager.tsx +++ b/src/components/SnippetManager.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useCallback, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Plus, MagnifyingGlass, CaretDown } from '@phosphor-icons/react' +import { Plus, MagnifyingGlass, CaretDown, CheckSquare, FolderOpen, X } from '@phosphor-icons/react' import { DropdownMenu, DropdownMenuContent, @@ -9,13 +9,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuLabel, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, } from '@/components/ui/dropdown-menu' import { SnippetCard } from '@/components/SnippetCard' import { SnippetDialog } from '@/components/SnippetDialog' import { SnippetViewer } from '@/components/SnippetViewer' import { EmptyState } from '@/components/EmptyState' import { NamespaceSelector } from '@/components/NamespaceSelector' -import { Snippet, SnippetTemplate } from '@/lib/types' +import { Snippet, SnippetTemplate, Namespace } from '@/lib/types' import { toast } from 'sonner' import { strings } from '@/lib/config' import templatesData from '@/data/templates.json' @@ -28,7 +31,8 @@ import { syncTemplatesFromJSON, getSnippetsByNamespace, ensureDefaultNamespace, - getAllNamespaces + getAllNamespaces, + bulkMoveSnippets } from '@/lib/db' const templates = templatesData as SnippetTemplate[] @@ -42,6 +46,9 @@ export function SnippetManager() { const [editingSnippet, setEditingSnippet] = useState(null) const [viewingSnippet, setViewingSnippet] = useState(null) const [selectedNamespaceId, setSelectedNamespaceId] = useState(null) + const [selectionMode, setSelectionMode] = useState(false) + const [selectedSnippetIds, setSelectedSnippetIds] = useState>(new Set()) + const [namespaces, setNamespaces] = useState([]) useEffect(() => { const loadData = async () => { @@ -50,8 +57,10 @@ export function SnippetManager() { await seedDatabase() await syncTemplatesFromJSON(templates) - const namespaces = await getAllNamespaces() - const defaultNamespace = namespaces.find(n => n.isDefault) + const loadedNamespaces = await getAllNamespaces() + setNamespaces(loadedNamespaces) + + const defaultNamespace = loadedNamespaces.find(n => n.isDefault) if (defaultNamespace) { setSelectedNamespaceId(defaultNamespace.id) const loadedSnippets = await getSnippetsByNamespace(defaultNamespace.id) @@ -198,6 +207,55 @@ export function SnippetManager() { } }, []) + const handleToggleSelectionMode = useCallback(() => { + setSelectionMode(!selectionMode) + setSelectedSnippetIds(new Set()) + }, [selectionMode]) + + const handleToggleSnippetSelection = useCallback((snippetId: string) => { + setSelectedSnippetIds((prev) => { + const newSet = new Set(prev) + if (newSet.has(snippetId)) { + newSet.delete(snippetId) + } else { + newSet.add(snippetId) + } + return newSet + }) + }, []) + + const handleSelectAll = useCallback(() => { + if (selectedSnippetIds.size === filteredSnippets.length) { + setSelectedSnippetIds(new Set()) + } else { + setSelectedSnippetIds(new Set(filteredSnippets.map(s => s.id))) + } + }, [filteredSnippets, selectedSnippetIds.size]) + + const handleBulkMove = useCallback(async (targetNamespaceId: string) => { + if (selectedSnippetIds.size === 0) { + toast.error('No snippets selected') + return + } + + try { + await bulkMoveSnippets(Array.from(selectedSnippetIds), targetNamespaceId) + const targetNamespace = namespaces.find(n => n.id === targetNamespaceId) + toast.success(`Moved ${selectedSnippetIds.size} snippet${selectedSnippetIds.size > 1 ? 's' : ''} to ${targetNamespace?.name || 'namespace'}`) + + setSelectedSnippetIds(new Set()) + setSelectionMode(false) + + if (selectedNamespaceId) { + const loadedSnippets = await getSnippetsByNamespace(selectedNamespaceId) + setSnippets(loadedSnippets) + } + } catch (error) { + console.error('Failed to bulk move snippets:', error) + toast.error('Failed to move snippets') + } + }, [selectedSnippetIds, namespaces, selectedNamespaceId]) + const allSnippets = snippets || [] if (loading) { @@ -248,113 +306,180 @@ export function SnippetManager() { className="pl-10" />
- - - - - - - - Blank Snippet - - - React Components - {templates.filter((t) => t.category === 'react').map((template) => ( - handleCreateFromTemplate(template.id)} - > -
- {template.title} - - {template.description} - -
+
+ + + + + + + + + Blank Snippet - ))} - - JavaScript / TypeScript - {templates.filter((t) => ['api', 'basics', 'async', 'types'].includes(t.category)).map((template) => ( - handleCreateFromTemplate(template.id)} - > -
- {template.title} - - {template.description} - -
-
- ))} - - CSS Layouts - {templates.filter((t) => t.category === 'layout').map((template) => ( - handleCreateFromTemplate(template.id)} - > -
- {template.title} - - {template.description} - -
-
- ))} - - Python - Project Euler - {templates.filter((t) => t.category === 'euler').map((template) => ( - handleCreateFromTemplate(template.id)} - > -
- {template.title} - - {template.description} - -
-
- ))} - - Python - Algorithms - {templates.filter((t) => t.category === 'algorithms' && t.language === 'Python').map((template) => ( - handleCreateFromTemplate(template.id)} - > -
- {template.title} - - {template.description} - -
-
- ))} - - Python - Interactive Programs - {templates.filter((t) => t.category === 'interactive').map((template) => ( - handleCreateFromTemplate(template.id)} - > -
- {template.title} - - {template.description} - -
-
- ))} -
-
+ + React Components + {templates.filter((t) => t.category === 'react').map((template) => ( + handleCreateFromTemplate(template.id)} + > +
+ {template.title} + + {template.description} + +
+
+ ))} + + JavaScript / TypeScript + {templates.filter((t) => ['api', 'basics', 'async', 'types'].includes(t.category)).map((template) => ( + handleCreateFromTemplate(template.id)} + > +
+ {template.title} + + {template.description} + +
+
+ ))} + + CSS Layouts + {templates.filter((t) => t.category === 'layout').map((template) => ( + handleCreateFromTemplate(template.id)} + > +
+ {template.title} + + {template.description} + +
+
+ ))} + + Python - Project Euler + {templates.filter((t) => t.category === 'euler').map((template) => ( + handleCreateFromTemplate(template.id)} + > +
+ {template.title} + + {template.description} + +
+
+ ))} + + Python - Algorithms + {templates.filter((t) => t.category === 'algorithms' && t.language === 'Python').map((template) => ( + handleCreateFromTemplate(template.id)} + > +
+ {template.title} + + {template.description} + +
+
+ ))} + + Python - Interactive Programs + {templates.filter((t) => t.category === 'interactive').map((template) => ( + handleCreateFromTemplate(template.id)} + > +
+ {template.title} + + {template.description} + +
+
+ ))} + + +
+ {selectionMode && ( +
+
+ + + {selectedSnippetIds.size} of {filteredSnippets.length} selected + +
+
+ + + + + + {namespaces + .filter(n => n.id !== selectedNamespaceId) + .map((namespace) => ( + handleBulkMove(namespace.id)} + > + {namespace.name} + {namespace.isDefault && ( + (Default) + )} + + ))} + + +
+
+ )} + {filteredSnippets.length === 0 ? (

{strings.noResults.title}

@@ -371,6 +496,9 @@ export function SnippetManager() { onCopy={handleCopyCode} onView={handleViewSnippet} onMove={handleMoveSnippet} + selectionMode={selectionMode} + isSelected={selectedSnippetIds.has(snippet.id)} + onToggleSelect={handleToggleSnippetSelection} /> ))}
diff --git a/src/lib/db.ts b/src/lib/db.ts index 3616038..2818038 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1124,6 +1124,26 @@ export async function moveSnippetToNamespace(snippetId: string, targetNamespaceI await saveDB() } +export async function bulkMoveSnippets(snippetIds: string[], targetNamespaceId: string): Promise { + const adapter = getFlaskAdapter() + if (adapter) { + await adapter.bulkMoveSnippets(snippetIds, targetNamespaceId) + return + } + + const db = await initDB() + const now = Date.now() + + for (const snippetId of snippetIds) { + db.run( + 'UPDATE snippets SET namespaceId = ?, updatedAt = ? WHERE id = ?', + [targetNamespaceId, now, snippetId] + ) + } + + await saveDB() +} + export async function validateDatabaseSchema(): Promise<{ valid: boolean; issues: string[] }> { try { const db = await initDB() diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 754a5f2..cde3ff4 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -235,4 +235,18 @@ export class FlaskStorageAdapter { throw new Error(`Failed to wipe database: ${response.statusText}`) } } + + async bulkMoveSnippets(snippetIds: string[], targetNamespaceId: string): Promise { + if (!this.isValidUrl()) { + throw new Error('Invalid Flask backend URL') + } + const response = await fetch(`${this.baseUrl}/api/snippets/bulk-move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ snippetIds, targetNamespaceId }) + }) + if (!response.ok) { + throw new Error(`Failed to bulk move snippets: ${response.statusText}`) + } + } }