/ src / features / dreamnode / hooks / useCanvasFiles.ts
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  }