useCanvasFiles.ts
1 /** 2 * useCanvasFiles - React Hook for scanning backside content in a DreamNode 3 * 4 * Scans the DreamNode repository for .canvas files and index.html to enable 5 * carousel navigation on the back side (DreamSong side). 6 * 7 * Carousel order: 8 * Index 0: HolonView (submodules — always present, managed by DreamSongSide) 9 * Index 1: Custom UI (index.html — only if present) 10 * Index 2+: Canvas files (DreamSongs) 11 */ 12 13 import { useState, useEffect, useMemo } from 'react'; 14 import { VaultService } from '../../../core/services/vault-service'; 15 16 export interface BacksideContentItem { 17 /** Content type: 'canvas' for DreamSongs, 'html' for custom UI */ 18 type: 'canvas' | 'html'; 19 /** Full path to the file (relative to vault) */ 20 path: string; 21 /** Filename without extension */ 22 filename: string; 23 /** Human-readable display title */ 24 displayTitle: string; 25 } 26 27 /** @deprecated Use BacksideContentItem instead */ 28 export type CanvasFileInfo = BacksideContentItem; 29 30 /** 31 * Convert PascalCase or camelCase filename to human-readable title 32 * "MyDreamSong" -> "My Dream Song" 33 * "dreamSong" -> "Dream Song" 34 */ 35 function filenameToDisplayTitle(filename: string): string { 36 // Add space before uppercase letters, handle consecutive capitals 37 const spaced = filename 38 .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') 39 .replace(/([a-z\d])([A-Z])/g, '$1 $2') 40 .replace(/([a-zA-Z])(\d)/g, '$1 $2') 41 .replace(/_/g, ' ') 42 .replace(/-/g, ' '); 43 44 // Capitalize first letter 45 return spaced.charAt(0).toUpperCase() + spaced.slice(1); 46 } 47 48 interface UseCanvasFilesResult { 49 /** Array of backside content items (HTML + canvas files) */ 50 backsideItems: BacksideContentItem[]; 51 /** Convenience: only the canvas files */ 52 canvasFiles: BacksideContentItem[]; 53 /** Whether this DreamNode has a custom UI (index.html) */ 54 hasCustomUI: boolean; 55 /** Loading state */ 56 isLoading: boolean; 57 /** Error message if any */ 58 error: string | null; 59 } 60 61 /** 62 * Hook to scan for backside content in a DreamNode repository. 63 * Detects both .canvas files (DreamSongs) and index.html (custom UI). 64 */ 65 export function useCanvasFiles( 66 repoPath: string, 67 vaultService: VaultService | null 68 ): UseCanvasFilesResult { 69 const [backsideItems, setBacksideItems] = useState<BacksideContentItem[]>([]); 70 const [isLoading, setIsLoading] = useState(false); 71 const [error, setError] = useState<string | null>(null); 72 73 // Memoize scan function 74 const scanForContent = useMemo(() => { 75 return async () => { 76 if (!repoPath || !vaultService) return; 77 78 setIsLoading(true); 79 setError(null); 80 81 try { 82 // Read directory contents 83 const entries = await vaultService.readdir(repoPath); 84 85 const items: BacksideContentItem[] = []; 86 87 // Check for index.html (custom UI) — prepended before canvas files 88 const hasIndexHtml = entries.some(entry => entry.isFile() && entry.name === 'index.html'); 89 if (hasIndexHtml) { 90 items.push({ 91 type: 'html', 92 path: vaultService.joinPath(repoPath, 'index.html'), 93 filename: 'index', 94 displayTitle: 'Custom UI' 95 }); 96 } 97 98 // Filter for .canvas files 99 const canvasEntries = entries 100 .filter(entry => entry.isFile() && entry.name.endsWith('.canvas')) 101 .map(entry => { 102 const filename = entry.name.replace('.canvas', ''); 103 return { 104 type: 'canvas' as const, 105 path: vaultService.joinPath(repoPath, entry.name), 106 filename, 107 displayTitle: filenameToDisplayTitle(filename) 108 }; 109 }) 110 // Sort alphabetically by filename, but put "DreamSong" first if present 111 .sort((a, b) => { 112 if (a.filename.toLowerCase() === 'dreamsong') return -1; 113 if (b.filename.toLowerCase() === 'dreamsong') return 1; 114 return a.filename.localeCompare(b.filename); 115 }); 116 117 items.push(...canvasEntries); 118 setBacksideItems(items); 119 } catch (err) { 120 console.error('Error scanning for backside content:', err); 121 setError(err instanceof Error ? err.message : 'Failed to scan backside content'); 122 setBacksideItems([]); 123 } finally { 124 setIsLoading(false); 125 } 126 }; 127 }, [repoPath, vaultService]); 128 129 // Run scan on mount and when dependencies change 130 useEffect(() => { 131 scanForContent(); 132 }, [scanForContent]); 133 134 // Derive convenience values 135 const canvasFiles = useMemo( 136 () => backsideItems.filter(item => item.type === 'canvas'), 137 [backsideItems] 138 ); 139 const hasCustomUI = useMemo( 140 () => backsideItems.some(item => item.type === 'html'), 141 [backsideItems] 142 ); 143 144 return { 145 backsideItems, 146 canvasFiles, 147 hasCustomUI, 148 isLoading, 149 error 150 }; 151 }