first commit
This commit is contained in:
125
src/components/ActionsPanel.tsx
Normal file
125
src/components/ActionsPanel.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { SplitResult } from '../pdf/pdfTypes';
|
||||
|
||||
interface ActionsPanelProps {
|
||||
hasPdf: boolean;
|
||||
onSplit: () => void;
|
||||
onExtractRange: (from: number, to: number) => void;
|
||||
isBusy: boolean;
|
||||
splitResults: SplitResult[];
|
||||
rangeDownloadUrl: string | null;
|
||||
rangeFilename: string | null;
|
||||
}
|
||||
|
||||
const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
hasPdf,
|
||||
onSplit,
|
||||
onExtractRange,
|
||||
isBusy,
|
||||
splitResults,
|
||||
rangeDownloadUrl,
|
||||
rangeFilename,
|
||||
}) => {
|
||||
const [fromPage, setFromPage] = useState<string>('');
|
||||
const [toPage, setToPage] = useState<string>('');
|
||||
|
||||
const handleExtractClick = () => {
|
||||
const from = parseInt(fromPage, 10);
|
||||
const to = parseInt(toPage, 10);
|
||||
if (!Number.isFinite(from) || !Number.isFinite(to)) return;
|
||||
onExtractRange(from, to);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>3. Actions</h2>
|
||||
<p>Split into single pages or extract a continuous range.</p>
|
||||
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="primary"
|
||||
disabled={!hasPdf || isBusy}
|
||||
onClick={onSplit}
|
||||
>
|
||||
{isBusy ? 'Splitting…' : 'Split into single pages'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr style={{ margin: '0.75rem 0', borderColor: '#e5e7eb' }} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
<div>
|
||||
<strong>Extract range:</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<label>
|
||||
From
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={fromPage}
|
||||
onChange={(e) => setFromPage(e.target.value)}
|
||||
style={{ marginLeft: '0.3rem', width: '4rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
To
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={toPage}
|
||||
onChange={(e) => setToPage(e.target.value)}
|
||||
style={{ marginLeft: '0.3rem', width: '4rem' }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={!hasPdf || isBusy}
|
||||
onClick={handleExtractClick}
|
||||
>
|
||||
{isBusy ? 'Working…' : 'Extract'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rangeDownloadUrl && rangeFilename && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<strong>Range result:</strong>{' '}
|
||||
<a
|
||||
className="download-link"
|
||||
href={rangeDownloadUrl}
|
||||
download={rangeFilename}
|
||||
>
|
||||
Download {rangeFilename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{splitResults.length > 0 && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||
<strong>Split result:</strong>
|
||||
<div>
|
||||
{splitResults.map((r) => {
|
||||
const url = URL.createObjectURL(r.blob);
|
||||
return (
|
||||
<a
|
||||
key={r.pageIndex}
|
||||
className="download-link"
|
||||
href={url}
|
||||
download={r.filename}
|
||||
onClick={() => {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
}}
|
||||
>
|
||||
Download {r.filename}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionsPanel;
|
||||
38
src/components/FileLoader.tsx
Normal file
38
src/components/FileLoader.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import type { PdfFile } from '../pdf/pdfTypes';
|
||||
|
||||
interface FileLoaderProps {
|
||||
pdf: PdfFile | null;
|
||||
onFileLoaded: (file: File) => void;
|
||||
}
|
||||
|
||||
const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onFileLoaded(file);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>1. Load PDF</h2>
|
||||
<p>Select a PDF file. Processing happens entirely in your browser.</p>
|
||||
<input type="file" accept="application/pdf" onChange={handleChange} />
|
||||
|
||||
{pdf && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||
<div>
|
||||
<strong>Loaded:</strong> {pdf.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Pages:</strong> {pdf.pageCount}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileLoader;
|
||||
57
src/components/Layout.tsx
Normal file
57
src/components/Layout.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
|
||||
export type ToolId = 'split' | 'reorder' | 'merge' | 'annotate';
|
||||
|
||||
interface LayoutProps {
|
||||
activeTool: ToolId;
|
||||
onToolChange: (tool: ToolId) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ activeTool, onToolChange, children }) => {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="app-sidebar">
|
||||
<div>
|
||||
<h1>PDF Workbench</h1>
|
||||
<small>Self-hosted, browser-based</small>
|
||||
</div>
|
||||
|
||||
<nav className="app-nav">
|
||||
<button
|
||||
className={activeTool === 'split' ? 'active' : ''}
|
||||
onClick={() => onToolChange('split')}
|
||||
>
|
||||
Split / Extract
|
||||
</button>
|
||||
<button
|
||||
className={activeTool === 'reorder' ? 'active' : ''}
|
||||
onClick={() => onToolChange('reorder')}
|
||||
>
|
||||
Reorder / Delete / Rotate
|
||||
</button>
|
||||
<button
|
||||
className={activeTool === 'merge' ? 'active' : ''}
|
||||
onClick={() => onToolChange('merge')}
|
||||
disabled
|
||||
title="Coming soon"
|
||||
>
|
||||
Merge PDFs
|
||||
</button>
|
||||
<button
|
||||
className={activeTool === 'annotate' ? 'active' : ''}
|
||||
onClick={() => onToolChange('annotate')}
|
||||
disabled
|
||||
title="Coming soon"
|
||||
>
|
||||
Annotations
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="app-main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
79
src/components/PageList.tsx
Normal file
79
src/components/PageList.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PageListProps {
|
||||
pageCount: number;
|
||||
selectedPages: number[];
|
||||
onTogglePage: (index: number) => void;
|
||||
thumbnails: string[] | null;
|
||||
}
|
||||
|
||||
const PageList: React.FC<PageListProps> = ({
|
||||
pageCount,
|
||||
selectedPages,
|
||||
onTogglePage,
|
||||
thumbnails,
|
||||
}) => {
|
||||
if (pageCount === 0) return null;
|
||||
|
||||
const pages = Array.from({ length: pageCount }, (_, i) => i);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>2. Pages</h2>
|
||||
<p>
|
||||
Thumbnails are generated in your browser. Click to select pages (used by
|
||||
future tools).
|
||||
</p>
|
||||
<div className="page-list">
|
||||
{pages.map((i) => {
|
||||
const selected = selectedPages.includes(i);
|
||||
const thumb = thumbnails?.[i];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`page-pill ${selected ? 'selected' : ''}`}
|
||||
onClick={() => onTogglePage(i)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.4rem',
|
||||
minWidth: '90px',
|
||||
}}
|
||||
>
|
||||
{thumb ? (
|
||||
<img
|
||||
src={thumb}
|
||||
alt={`Page ${i + 1}`}
|
||||
style={{
|
||||
maxHeight: '100px',
|
||||
width: 'auto',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: 'white',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '80px',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px dashed #d1d5db',
|
||||
background: '#f3f4f6',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>Page {i + 1}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageList;
|
||||
225
src/components/ReorderPanel.tsx
Normal file
225
src/components/ReorderPanel.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface ReorderPanelProps {
|
||||
pageCount: number;
|
||||
thumbnails: string[] | null;
|
||||
isBusy: boolean;
|
||||
hasPdf: boolean;
|
||||
rotations: Record<number, number>;
|
||||
onRotate: (pageIndex: number) => void;
|
||||
onExportReordered: (order: number[]) => void;
|
||||
reorderDownloadUrl: string | null;
|
||||
reorderFilename: string | null;
|
||||
}
|
||||
|
||||
const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
pageCount,
|
||||
thumbnails,
|
||||
isBusy,
|
||||
hasPdf,
|
||||
rotations,
|
||||
onRotate,
|
||||
onExportReordered,
|
||||
reorderDownloadUrl,
|
||||
reorderFilename,
|
||||
}) => {
|
||||
const [order, setOrder] = useState<number[]>([]);
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageCount > 0) {
|
||||
setOrder(Array.from({ length: pageCount }, (_, i) => i));
|
||||
} else {
|
||||
setOrder([]);
|
||||
}
|
||||
}, [pageCount]);
|
||||
|
||||
const handleDragStart = (index: number) => (e: React.DragEvent) => {
|
||||
setDraggingIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (index: number) => (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (index: number) => (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (draggingIndex === null || draggingIndex === index) return;
|
||||
|
||||
setOrder((prev) => {
|
||||
const updated = [...prev];
|
||||
const [moved] = updated.splice(draggingIndex, 1);
|
||||
updated.splice(index, 0, moved);
|
||||
return updated;
|
||||
});
|
||||
setDraggingIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggingIndex(null);
|
||||
};
|
||||
|
||||
const handleDelete = (visualIndex: number) => () => {
|
||||
setOrder((prev) => prev.filter((_, idx) => idx !== visualIndex));
|
||||
};
|
||||
|
||||
const handleRotateClick = (pageIndex: number) => () => {
|
||||
onRotate(pageIndex);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!hasPdf || order.length === 0) return;
|
||||
onExportReordered(order);
|
||||
};
|
||||
|
||||
if (!hasPdf) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Reorder pages</h2>
|
||||
<p>Load a PDF first to reorder, delete, or rotate its pages.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Reorder / delete / rotate</h2>
|
||||
<p>
|
||||
Drag pages to reorder them. Use rotate and delete controls below each
|
||||
thumbnail. All changes stay in memory until you export a new PDF.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{order.map((pageIndex, visualIndex) => {
|
||||
const thumb = thumbnails?.[pageIndex];
|
||||
const isDragging = visualIndex === draggingIndex;
|
||||
const rotation = rotations[pageIndex] ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${pageIndex}-${visualIndex}`}
|
||||
draggable
|
||||
onDragStart={handleDragStart(visualIndex)}
|
||||
onDragOver={handleDragOver(visualIndex)}
|
||||
onDrop={handleDrop(visualIndex)}
|
||||
onDragEnd={handleDragEnd}
|
||||
style={{
|
||||
width: '130px',
|
||||
padding: '0.4rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: isDragging ? '2px solid #2563eb' : '1px solid #e5e7eb',
|
||||
background: isDragging ? '#dbeafe' : '#f9fafb',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
>
|
||||
{thumb ? (
|
||||
<img
|
||||
src={thumb}
|
||||
alt={`Page ${pageIndex + 1}`}
|
||||
style={{
|
||||
maxHeight: '90px',
|
||||
width: 'auto',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: 'white',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '80px',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px dashed #d1d5db',
|
||||
background: '#f3f4f6',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span style={{ fontSize: '0.8rem' }}>Page {pageIndex + 1}</span>
|
||||
<span style={{ fontSize: '0.7rem', color: '#6b7280' }}>
|
||||
Pos {visualIndex + 1} · Rot {rotation}°
|
||||
</span>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
marginTop: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRotateClick(pageIndex)}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
fontSize: '0.75rem',
|
||||
background: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
↻ 90°
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete(visualIndex)}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
fontSize: '0.75rem',
|
||||
background: '#fecaca',
|
||||
color: '#b91c1c',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Remove this page from the exported PDF"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="primary"
|
||||
disabled={!hasPdf || isBusy || order.length === 0}
|
||||
onClick={handleExport}
|
||||
>
|
||||
{isBusy ? 'Exporting…' : 'Export reordered PDF'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{reorderDownloadUrl && reorderFilename && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<strong>Reordered result:</strong>{' '}
|
||||
<a
|
||||
href={reorderDownloadUrl}
|
||||
download={reorderFilename}
|
||||
className="download-link"
|
||||
>
|
||||
Download {reorderFilename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReorderPanel;
|
||||
Reference in New Issue
Block a user