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:
ExportLinksManagerclass 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>
);
}
Adding Export Links
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
- Lazy Loading: Component only renders when exports exist
- Memory Management: Proper cleanup of event listeners
- Storage Optimization: Efficient localStorage usage
- Update Batching: Minimal re-renders through careful state updates
- Icon Optimization: SVG icons loaded inline for performance
Security Features
- Safe Links:
rel="noopener noreferrer"for external links - Target Blank: Opens downloads in new tab/window
- Error Handling: Graceful localStorage error handling
- 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
- Singleton Testing: Use
clearAll()method for test cleanup - LocalStorage Mocking: Mock localStorage for unit tests
- Subscription Testing: Verify listener registration/cleanup
- Component Testing: Test with various export states
- Integration Testing: Test with actual export workflows