Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 67 additions & 9 deletions src/ui/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ import { themeManager } from '../lib/theme/ThemeManager';

// Lazy-loaded modules (code splitting)
// These will be loaded on-demand to reduce initial bundle size
// import { AuthSession } from '../lib/auth/session.js';
// import { CloudSyncService } from '../lib/cloud/sync.js';
// import { AuthModal } from './components/auth/AuthModal.js';
// import { UserMenu } from './components/auth/UserMenu.js';
// import { Dashboard } from './components/Dashboard.js';
import { AuthSession } from '../lib/auth/session.js';
import { CloudSyncService } from '../lib/cloud/sync.js';
import { AuthModal } from './components/auth/AuthModal.js';
import { UserMenu } from './components/auth/UserMenu.js';
import { Dashboard } from './components/Dashboard.js';

import { loadPdf, renderPageToCanvas, getPageCount } from '../lib/pdf/load';
import { findTextBoxes, extractPageText } from '../lib/pdf/find';
Expand All @@ -41,7 +41,7 @@ import { detectAllPIIWithMetadata, type DetectionOptions } from '../lib/detect/p
import type { DetectionResult } from '../lib/detect/merger';
// Lazy-loaded: ML detection module (~280KB)
// import { loadMLModel, isMLAvailable } from '../lib/detect/ml';
import { saveBlob } from '../lib/fs/io';
import { pickFiles, saveBlob, ACCEPT_ALL } from '../lib/fs/io';
import { mapPIIToOCRBoxes, expandBoxes as expandOCRBoxes } from '../lib/ocr/mapper';

import { FormatRegistry } from '../lib/formats/base/FormatRegistry';
Expand Down Expand Up @@ -120,7 +120,8 @@ export class App {
() => this.openSettings(),
() => this.handleShowAuth(),
() => this.handleShowDashboard(),
() => this.handleBatchExport()
() => this.handleBatchExport(),
() => void this.handleLoadFromDevice()
);

// Check if user is logged in and initialize cloud sync
Expand Down Expand Up @@ -1125,6 +1126,18 @@ export class App {
}
}

private getRedactedFileName(originalName: string, fallbackExtension: string = 'pdf'): string {
const lastDotIndex = originalName.lastIndexOf('.');

if (lastDotIndex > 0) {
const base = originalName.substring(0, lastDotIndex);
const ext = originalName.substring(lastDotIndex);
return `${base}-redacted${ext}`;
}

return `${originalName}-redacted.${fallbackExtension}`;
}

private showSanitizeModal() {
const pdfBytes = this.pdfBytes ? new Uint8Array(this.pdfBytes) : null;

Expand Down Expand Up @@ -1456,6 +1469,7 @@ export class App {
);

this.toolbar.showUserMenu(this.userMenu.getElement());
this.pdfViewer.setSaveToCloudHandler(() => void this.handlePdfSaveToCloud());
}

/**
Expand Down Expand Up @@ -1498,6 +1512,7 @@ export class App {
this.cloudSync = null;
this.userMenu = null;
this.toolbar.showLoginButton();
this.pdfViewer.setSaveToCloudHandler(null);
this.toast.info('Signed out');
} catch (error) {
console.error('Logout error:', error);
Expand Down Expand Up @@ -1563,21 +1578,46 @@ export class App {
try {
const blob = new Blob([this.lastExportedPdfBytes], { type: 'application/pdf' });
const originalName = this.files[this.currentFileIndex].file.name;
const newName = originalName.replace('.pdf', '-redacted.pdf');
const newName = this.getRedactedFileName(originalName, 'pdf');

this.toast.info(`Saving ${newName} locally...`);
await saveBlob(blob, newName);

// Show success animation
const successAnim = new SuccessAnimation();
successAnim.show();

this.toast.success('PDF downloaded successfully!');
this.toast.success(`Saved locally as ${newName}`);
} catch (error) {
this.toast.error('Failed to download PDF');
console.error(error);
}
}

private async handlePdfSaveToCloud() {
if (!this.lastExportedPdfBytes) {
this.toast.error('No PDF to save');
return;
}

if (!this.cloudSync) {
this.toast.error('Sign in to sync to the cloud');
return;
}

try {
const originalName = this.files[this.currentFileIndex]?.file.name || 'redacted.pdf';
const filename = this.getRedactedFileName(originalName, 'pdf');
this.toast.info(`Uploading ${filename} to cloud...`);

await this.cloudSync.uploadFile(this.lastExportedPdfBytes, filename, 'application/pdf');
this.toast.success(`Saved ${filename} to your cloud vault`);
} catch (error) {
console.error('Cloud save error:', error);
this.toast.error('Failed to save to cloud');
}
}

private handlePdfViewerBack() {
// Hide PDF viewer and show the editing view
this.pdfViewer.hide();
Expand All @@ -1598,6 +1638,24 @@ export class App {
this.toast.info('Back to editing mode');
}

private async handleLoadFromDevice() {
try {
const files = await pickFiles(ACCEPT_ALL, true);

if (files.length === 0) {
this.toast.info('No files selected');
return;
}

this.toast.info('Opening files from your device...');
await this.handleFiles(files);
this.toast.success('Loaded files from your device');
} catch (error) {
console.error('Failed to load from device:', error);
this.toast.error('Could not load files from your device');
}
}

private async handleStartRedacting() {
// Hide the PDF viewer
this.pdfViewer.hide();
Expand Down
22 changes: 20 additions & 2 deletions src/ui/components/PdfViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class PdfViewer {
private onDownload: () => void;
private onBack: () => void;
private onStartRedacting: (() => void) | null = null;
private onSaveToCloud: (() => void) | null = null;
private mode: 'initial' | 'preview' = 'preview';

constructor(onDownload: () => void, onBack: () => void, onStartRedacting?: () => void) {
Expand Down Expand Up @@ -40,8 +41,11 @@ export class PdfViewer {
<button id="pdf-start-redacting-btn" class="btn btn-primary" aria-label="Start redacting" style="display: none;">
<span>Start Redacting →</span>
</button>
<button id="pdf-download-btn" class="btn btn-primary" aria-label="Download redacted PDF" style="display: none;">
<span>Download PDF</span>
<button id="pdf-download-btn" class="btn btn-secondary" aria-label="Save redacted PDF locally" style="display: none;">
<span>Save Locally</span>
</button>
<button id="pdf-save-cloud-btn" class="btn btn-primary" aria-label="Save redacted PDF to cloud" style="display: none;">
<span>Save to Cloud</span>
</button>
</div>
</div>
Expand Down Expand Up @@ -70,6 +74,12 @@ export class PdfViewer {
}
});

viewer.querySelector('#pdf-save-cloud-btn')?.addEventListener('click', () => {
if (this.onSaveToCloud) {
this.onSaveToCloud();
}
});

return viewer;
}

Expand Down Expand Up @@ -100,19 +110,22 @@ export class PdfViewer {
const backBtn = this.element.querySelector('#pdf-back-btn') as HTMLElement;
const startBtn = this.element.querySelector('#pdf-start-redacting-btn') as HTMLElement;
const downloadBtn = this.element.querySelector('#pdf-download-btn') as HTMLElement;
const saveCloudBtn = this.element.querySelector('#pdf-save-cloud-btn') as HTMLElement;

if (this.mode === 'initial') {
titleEl.textContent = 'PDF Document';
subtitleEl.textContent = 'Review your document using the browser\'s native PDF viewer. Click "Start Redacting" when ready.';
backBtn.style.display = 'none';
startBtn.style.display = 'inline-block';
downloadBtn.style.display = 'none';
saveCloudBtn.style.display = 'none';
} else {
titleEl.textContent = 'Redacted PDF Preview';
subtitleEl.textContent = 'Your document has been redacted. Review it below.';
backBtn.style.display = 'inline-block';
startBtn.style.display = 'none';
downloadBtn.style.display = 'inline-block';
saveCloudBtn.style.display = this.onSaveToCloud ? 'inline-block' : 'none';
}
}

Expand Down Expand Up @@ -185,4 +198,9 @@ export class PdfViewer {
this.blobUrl = null;
}
}

setSaveToCloudHandler(handler: (() => void) | null): void {
this.onSaveToCloud = handler;
this.updateHeader();
}
}
34 changes: 31 additions & 3 deletions src/ui/components/Toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class Toolbar {
private onShowAuth: (() => void) | null = null;
private onShowDashboard: (() => void) | null = null;
private onBatchExport: (() => void) | null = null;
private onLoadFromDevice: (() => void) | null = null;

constructor(
onChange: (options: ToolbarOptions) => void,
Expand All @@ -32,7 +33,8 @@ export class Toolbar {
onSettings: () => void,
onShowAuth?: () => void,
onShowDashboard?: () => void,
onBatchExport?: () => void
onBatchExport?: () => void,
onLoadFromDevice?: () => void
) {
this.options = {
findEmails: true,
Expand All @@ -51,6 +53,7 @@ export class Toolbar {
this.onShowAuth = onShowAuth || null;
this.onShowDashboard = onShowDashboard || null;
this.onBatchExport = onBatchExport || null;
this.onLoadFromDevice = onLoadFromDevice || null;
this.element = this.createToolbar();
}

Expand Down Expand Up @@ -83,6 +86,16 @@ export class Toolbar {
</svg>
<span>New File</span>
</button>
<button id="btn-load-device" class="btn btn-secondary" aria-label="Load a saved export from this device">
<svg class="btn-icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 16 12 12 8 16"/>
<line x1="12" y1="12" x2="12" y2="21"/>
<path d="M20 20H4"/>
<polyline points="16 8 12 4 8 8"/>
<line x1="12" y1="4" x2="12" y2="13"/>
</svg>
<span>Load from Device</span>
</button>
<button id="btn-reset" class="btn btn-secondary" aria-label="Load new files">
<svg class="btn-icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
Expand Down Expand Up @@ -163,6 +176,10 @@ export class Toolbar {
this.onReset();
});

toolbar.querySelector('#btn-load-device')?.addEventListener('click', () => {
this.onLoadFromDevice?.();
});

toolbar.querySelector('#btn-settings')?.addEventListener('click', () => {
this.onSettings();
});
Expand Down Expand Up @@ -225,6 +242,9 @@ export class Toolbar {
</svg>
<span style="vertical-align: middle;">Sign In</span>
</button>
<p style="margin: 10px 0 0 0; color: var(--text-secondary); font-size: 0.85rem; line-height: 1.4;">
Offline-first by design: files are saved locally. Cloud storage is optional if you want synced backups.
</p>
`;
const button = container.querySelector('.login-button') as HTMLButtonElement;
button?.addEventListener('click', () => this.onShowAuth?.());
Expand All @@ -245,8 +265,16 @@ export class Toolbar {
showUserMenu(userMenuElement: HTMLElement): void {
const container = this.element.querySelector('.auth-container');
if (container) {
container.innerHTML = '';
container.appendChild(userMenuElement);
container.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 8px;">
<div class="user-menu-wrapper"></div>
<p style="margin: 0; color: var(--text-secondary); font-size: 0.85rem; line-height: 1.4;">
Offline-first by design: files are saved locally. Cloud storage is optional if you want synced backups.
</p>
</div>
`;
const wrapper = container.querySelector('.user-menu-wrapper');
wrapper?.appendChild(userMenuElement);
}
}
}