phase 15
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 18s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / CI Summary (push) Successful in 0s

This commit is contained in:
Danilo Reyes
2025-11-02 15:16:00 -06:00
parent c68a6a7d01
commit d4fbdf9273
9 changed files with 1024 additions and 19 deletions

View File

@@ -0,0 +1,123 @@
/**
* Export API client for downloading and exporting board content.
*/
import { apiClient } from './client';
export interface ExportInfo {
board_id: string;
image_count: number;
total_size_bytes: number;
estimated_zip_size_bytes: number;
}
/**
* Download a single image.
*
* @param imageId - Image UUID
*/
export async function downloadImage(imageId: string): Promise<void> {
const response = await fetch(`/api/v1/images/${imageId}/download`, {
method: 'GET',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Failed to download image');
}
// Get filename from Content-Disposition header
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'download';
if (contentDisposition) {
const matches = /filename="([^"]+)"/.exec(contentDisposition);
if (matches) {
filename = matches[1];
}
}
// Download the file
const blob = await response.blob();
downloadBlob(blob, filename);
}
/**
* Export board as ZIP file containing all images.
*
* @param boardId - Board UUID
*/
export async function exportBoardZip(boardId: string): Promise<void> {
const response = await fetch(`/api/v1/boards/${boardId}/export/zip`, {
method: 'GET',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Failed to export board as ZIP');
}
const blob = await response.blob();
downloadBlob(blob, 'board_export.zip');
}
/**
* Export board as a composite image.
*
* @param boardId - Board UUID
* @param scale - Resolution scale (1x, 2x, 4x)
* @param format - Output format (PNG or JPEG)
*/
export async function exportBoardComposite(
boardId: string,
scale: number = 1.0,
format: 'PNG' | 'JPEG' = 'PNG'
): Promise<void> {
const response = await fetch(
`/api/v1/boards/${boardId}/export/composite?scale=${scale}&format=${format}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
}
);
if (!response.ok) {
throw new Error('Failed to export board as composite image');
}
const extension = format === 'PNG' ? 'png' : 'jpg';
const blob = await response.blob();
downloadBlob(blob, `board_composite.${extension}`);
}
/**
* Get export information for a board.
*
* @param boardId - Board UUID
* @returns Export information
*/
export async function getExportInfo(boardId: string): Promise<ExportInfo> {
return apiClient.get<ExportInfo>(`/boards/${boardId}/export/info`);
}
/**
* Helper function to trigger download of a blob.
*
* @param blob - Blob to download
* @param filename - Filename for download
*/
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,339 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
exportBoardZip,
exportBoardComposite,
getExportInfo,
type ExportInfo,
} from '$lib/api/export';
export let boardId: string;
export let onClose: () => void;
let exportInfo: ExportInfo | null = null;
let loading = false;
let error = '';
let exportType: 'zip' | 'composite' = 'zip';
let compositeScale: number = 1.0;
let compositeFormat: 'PNG' | 'JPEG' = 'PNG';
let progress = 0;
let exporting = false;
onMount(async () => {
await loadExportInfo();
});
async function loadExportInfo() {
try {
loading = true;
exportInfo = await getExportInfo(boardId);
} catch (err: any) {
error = `Failed to load export info: ${err.message || err}`;
} finally {
loading = false;
}
}
async function handleExport() {
try {
exporting = true;
progress = 0;
error = '';
// Simulate progress (since we don't have real progress tracking yet)
const progressInterval = setInterval(() => {
if (progress < 90) {
progress += 10;
}
}, 200);
if (exportType === 'zip') {
await exportBoardZip(boardId);
} else {
await exportBoardComposite(boardId, compositeScale, compositeFormat);
}
clearInterval(progressInterval);
progress = 100;
// Close modal after short delay
setTimeout(() => {
onClose();
}, 500);
} catch (err: any) {
error = `Export failed: ${err.message || err}`;
} finally {
exporting = false;
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<div
class="modal-overlay"
role="button"
tabindex="0"
on:click={handleOverlayClick}
on:keydown={handleKeyDown}
>
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Export Board</h2>
<button class="close-btn" on:click={onClose}>×</button>
</div>
<div class="modal-body">
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if loading}
<p>Loading export information...</p>
{:else if exportInfo}
<div class="export-info">
<p><strong>{exportInfo.image_count}</strong> images</p>
<p>Total size: <strong>{formatBytes(exportInfo.total_size_bytes)}</strong></p>
</div>
<div class="export-options">
<div class="form-group">
<label>
<input type="radio" bind:group={exportType} value="zip" />
<span>ZIP Archive</span>
</label>
<p class="option-description">
Download all images as individual files in a ZIP archive
</p>
</div>
<div class="form-group">
<label>
<input type="radio" bind:group={exportType} value="composite" />
<span>Composite Image</span>
</label>
<p class="option-description">Export the entire board layout as a single image</p>
</div>
{#if exportType === 'composite'}
<div class="composite-options">
<div class="form-group">
<label for="scale">Resolution:</label>
<select id="scale" bind:value={compositeScale}>
<option value={0.5}>0.5x (Half)</option>
<option value={1.0}>1x (Original)</option>
<option value={2.0}>2x (Double)</option>
<option value={4.0}>4x (Quadruple)</option>
</select>
</div>
<div class="form-group">
<label for="format">Format:</label>
<select id="format" bind:value={compositeFormat}>
<option value="PNG">PNG (Lossless)</option>
<option value="JPEG">JPEG (Smaller file)</option>
</select>
</div>
</div>
{/if}
</div>
{#if exporting}
<div class="progress-section">
<div class="progress-bar">
<div class="progress-fill" style="width: {progress}%"></div>
</div>
<p class="progress-text">{progress}% Complete</p>
</div>
{/if}
<div class="modal-actions">
<button class="btn-cancel" on:click={onClose} disabled={exporting}> Cancel </button>
<button class="btn-export" on:click={handleExport} disabled={exporting}>
{exporting ? 'Exporting...' : 'Export'}
</button>
</div>
{/if}
</div>
</div>
</div>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding: 1.5rem;
}
.error-message {
background: #fee2e2;
color: #991b1b;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.export-info {
background: #f3f4f6;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
}
.export-info p {
margin: 0.5rem 0;
}
.export-options {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
cursor: pointer;
}
.option-description {
margin: 0.25rem 0 0 1.75rem;
font-size: 0.875rem;
color: #6b7280;
}
.composite-options {
margin-left: 1.75rem;
padding: 1rem;
background: #f9fafb;
border-radius: 4px;
}
select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
margin-top: 0.25rem;
}
.progress-section {
margin: 1.5rem 0;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
.progress-text {
text-align: center;
margin-top: 0.5rem;
font-size: 0.875rem;
color: #6b7280;
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.btn-cancel,
.btn-export {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-cancel {
background: #e5e7eb;
color: #374151;
}
.btn-export {
background: #3b82f6;
color: white;
}
.btn-cancel:disabled,
.btn-export:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>