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
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:
123
frontend/src/lib/api/export.ts
Normal file
123
frontend/src/lib/api/export.ts
Normal 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);
|
||||
}
|
||||
339
frontend/src/lib/components/export/ExportModal.svelte
Normal file
339
frontend/src/lib/components/export/ExportModal.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user