QUARTO_EXPORT_LINKS_COMPONENT.md

Overview

The QuartoExportLinks component manages and displays export download links for message content with persistent storage and real-time updates. It uses a singleton pattern for global state management and provides file format-specific icons with timestamps.

Purpose

  • Export Management: Track and display document export links per message
  • Persistent Storage: Maintain export history using localStorage
  • Real-time Updates: Subscribe to export changes across the application
  • Format Support: Handle multiple export formats (PDF, HTML, DOCX)

Key Features

Export Display

  • Format Icons: Visual indicators for PDF, HTML, DOCX file types
  • Timestamp Display: Show export creation time for each link
  • Link Management: Direct download links with proper security attributes
  • Responsive Layout: Flexible grid layout for multiple export links

Global State Management

  • Singleton Pattern: ExportLinksManager class for centralized state
  • Subscription System: Real-time updates via observer pattern
  • Local Storage: Persistent export history across sessions
  • Message Association: Export links organized by message ID

Component Interface

interface QuartoExportLinksProps {
  messageId: string;    // Unique message identifier
  className?: string;   // Additional CSS classes
}

interface DownloadInfo {
  url: string;          // Download URL
  filename: string;     // Original filename
  format: string;       // File format (pdf, html, docx)
  timestamp: number;    // Creation timestamp
}

interface ExportRecord {
  messageId: string;           // Message identifier
  exports: DownloadInfo[];     // Array of export links
}

Core Functionality

1. Export Display Component

// Main component with subscription to updates
export function QuartoExportLinks({ messageId, className = '' }: QuartoExportLinksProps) {
  const [exports, setExports] = useState<DownloadInfo[]>([]);

  useEffect(() => {
    // Get exports from global state
    const allExports = ExportLinksManager.getExportsForMessage(messageId);
    setExports(allExports);

    // Subscribe to changes
    const handleExportsUpdated = (updatedMessageId: string, updatedExports: DownloadInfo[]) => {
      if (updatedMessageId === messageId) {
        setExports([...updatedExports]);
      }
    };

    ExportLinksManager.subscribe(handleExportsUpdated);
    return () => ExportLinksManager.unsubscribe(handleExportsUpdated);
  }, [messageId]);

  if (exports.length === 0) return null;

  return (
    <div className={`quarto-export-links ${className}`}>
      <div className="flex flex-wrap gap-2">
        {exports.map((exportItem, index) => (
          <a
            key={`${messageId}-export-${index}`}
            href={exportItem.url}
            target="_blank"
            rel="noopener noreferrer"
            className="flex items-center gap-1 px-2 py-1 text-xs bg-muted hover:bg-accent text-foreground rounded transition-colors"
          >
            {getFormatIcon(exportItem.format)}
            <span>{exportItem.format.toUpperCase()}</span>
            <span className="text-muted-foreground text-xs">({formatTime(exportItem.timestamp)})</span>
          </a>
        ))}
      </div>
    </div>
  );
}

2. Format-Specific Icons

// Icon mapping for different file formats
const getFormatIcon = (format: string) => {
  switch (format.toLowerCase()) {
    case 'pdf':
      return (
        <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
          <path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
        </svg>
      );
    case 'html':
      return (
        <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
          <path fillRule="evenodd" d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z" clipRule="evenodd" />
        </svg>
      );
    case 'docx':
    default:
      return (
        <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
          <path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
        </svg>
      );
  }
};

// Format timestamp for display
const formatTime = (timestamp: number) => {
  return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};

3. ExportLinksManager Singleton

// Global state management class
export class ExportLinksManager {
  private static exports: ExportRecord[] = [];
  private static listeners: ((messageId: string, exports: DownloadInfo[]) => void)[] = [];
  private static STORAGE_KEY = 'quarto_exports';

  // Initialize from localStorage
  static initialize() {
    try {
      const storedExports = localStorage.getItem(this.STORAGE_KEY);
      if (storedExports) {
        this.exports = JSON.parse(storedExports);
      }
    } catch (e) {
      console.error('Failed to load exports from local storage:', e);
    }
  }

  // Add new export link
  static addExport(messageId: string, downloadInfo: DownloadInfo) {
    const messageIndex = this.exports.findIndex(record => record.messageId === messageId);
    
    if (messageIndex >= 0) {
      this.exports[messageIndex].exports.push(downloadInfo);
    } else {
      this.exports.push({ messageId, exports: [downloadInfo] });
    }
    
    this.saveToStorage();
    this.notifyListeners(messageId);
  }

  // Get exports for specific message
  static getExportsForMessage(messageId: string): DownloadInfo[] {
    const record = this.exports.find(record => record.messageId === messageId);
    return record ? [...record.exports] : [];
  }

  // Subscription management
  static subscribe(listener: (messageId: string, exports: DownloadInfo[]) => void) {
    this.listeners.push(listener);
  }

  static unsubscribe(listener: (messageId: string, exports: DownloadInfo[]) => void) {
    this.listeners = this.listeners.filter(l => l !== listener);
  }
}

Integration Examples

Basic Usage

function MessageDisplay({ message }) {
  return (
    <div className="message">
      <div className="message-content">{message.content}</div>
      <QuartoExportLinks messageId={message.id} />
    </div>
  );
}
function ExportButton({ messageId, content }) {
  const handleExport = async (format: 'pdf' | 'html' | 'docx') => {
    try {
      const response = await fetch('/api/quarto', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content, format })
      });
      
      const data = await response.json();
      
      // Add export link to global state
      ExportLinksManager.addExport(messageId, {
        url: data.downloadUrl,
        filename: data.filename,
        format: format,
        timestamp: Date.now()
      });
    } catch (error) {
      console.error('Export failed:', error);
    }
  };

  return (
    <div className="export-controls">
      <button onClick={() => handleExport('pdf')}>Export PDF</button>
      <button onClick={() => handleExport('html')}>Export HTML</button>
      <button onClick={() => handleExport('docx')}>Export DOCX</button>
    </div>
  );
}

Chat Integration

function ChatMessage({ message }) {
  return (
    <div className="chat-message">
      <div className="message-header">
        <span className="timestamp">{message.timestamp}</span>
      </div>
      <div className="message-body">
        {message.content}
      </div>
      <div className="message-footer">
        <QuartoExportLinks 
          messageId={message.id} 
          className="mt-2"
        />
      </div>
    </div>
  );
}

State Management

Singleton Pattern Benefits

  • Global Access: Export state accessible from anywhere in the application
  • Memory Efficiency: Single instance manages all export data
  • Consistency: Centralized state prevents data inconsistencies
  • Persistence: Automatic localStorage integration

Subscription System

// Component automatically updates when exports change
useEffect(() => {
  const handleUpdate = (messageId: string, exports: DownloadInfo[]) => {
    if (messageId === currentMessageId) {
      setExports([...exports]);
    }
  };

  ExportLinksManager.subscribe(handleUpdate);
  return () => ExportLinksManager.unsubscribe(handleUpdate);
}, [currentMessageId]);

Performance Considerations

  1. Lazy Loading: Component only renders when exports exist
  2. Memory Management: Proper cleanup of event listeners
  3. Storage Optimization: Efficient localStorage usage
  4. Update Batching: Minimal re-renders through careful state updates
  5. Icon Optimization: SVG icons loaded inline for performance

Security Features

  1. Safe Links: rel="noopener noreferrer" for external links
  2. Target Blank: Opens downloads in new tab/window
  3. Error Handling: Graceful localStorage error handling
  4. Data Validation: Type-safe interfaces prevent malformed data

Browser Compatibility

localStorage Support

  • Modern Browsers: Full support in all modern browsers
  • Fallback: Graceful degradation when localStorage unavailable
  • Error Handling: Try-catch blocks prevent crashes

File Download Support

  • Download Attribute: Modern browsers support direct downloads
  • Fallback: Target blank for older browser compatibility
  • MIME Types: Proper content-type handling for different formats

Testing Considerations

  1. Singleton Testing: Use clearAll() method for test cleanup
  2. LocalStorage Mocking: Mock localStorage for unit tests
  3. Subscription Testing: Verify listener registration/cleanup
  4. Component Testing: Test with various export states
  5. Integration Testing: Test with actual export workflows