first commit
This commit is contained in:
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Self-hosted PDF Workbench</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "pdf-workbench",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "echo \"no lint configured\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdfjs-dist": "^4.6.82",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.2",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
254
src/App.tsx
Normal file
254
src/App.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Layout, { type ToolId } from './components/Layout';
|
||||||
|
import FileLoader from './components/FileLoader';
|
||||||
|
import PageList from './components/PageList';
|
||||||
|
import ActionsPanel from './components/ActionsPanel';
|
||||||
|
import ReorderPanel from './components/ReorderPanel';
|
||||||
|
import type { PdfFile, SplitResult } from './pdf/pdfTypes';
|
||||||
|
import {
|
||||||
|
loadPdfFromFile,
|
||||||
|
splitIntoSinglePages,
|
||||||
|
extractRange,
|
||||||
|
exportReordered,
|
||||||
|
} from './pdf/pdfService';
|
||||||
|
import {
|
||||||
|
generateThumbnails,
|
||||||
|
generateThumbnailsWithRotations,
|
||||||
|
} from './pdf/pdfThumbnailService';
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
const [activeTool, setActiveTool] = useState<ToolId>('split');
|
||||||
|
const [pdf, setPdf] = useState<PdfFile | null>(null);
|
||||||
|
const [selectedPages, setSelectedPages] = useState<number[]>([]);
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [splitResults, setSplitResults] = useState<SplitResult[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [baseThumbnails, setBaseThumbnails] = useState<string[] | null>(null);
|
||||||
|
const [reorderThumbnails, setReorderThumbnails] = useState<string[] | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rangeUrl, setRangeUrl] = useState<string | null>(null);
|
||||||
|
const [rangeFilename, setRangeFilename] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [reorderUrl, setReorderUrl] = useState<string | null>(null);
|
||||||
|
const [reorderFilename, setReorderFilename] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [rotations, setRotations] = useState<Record<number, number>>({});
|
||||||
|
|
||||||
|
const handleFileLoaded = async (file: File) => {
|
||||||
|
setError(null);
|
||||||
|
setSplitResults([]);
|
||||||
|
setSelectedPages([]);
|
||||||
|
setRangeUrl(null);
|
||||||
|
setRangeFilename(null);
|
||||||
|
setReorderUrl(null);
|
||||||
|
setReorderFilename(null);
|
||||||
|
setBaseThumbnails(null);
|
||||||
|
setReorderThumbnails(null);
|
||||||
|
setRotations({});
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
const loaded = await loadPdfFromFile(file);
|
||||||
|
setPdf(loaded);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('Failed to load PDF (see console).');
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pdf) {
|
||||||
|
setBaseThumbnails(null);
|
||||||
|
setReorderThumbnails(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const thumbs = await generateThumbnails(pdf.arrayBuffer);
|
||||||
|
if (!cancelled) {
|
||||||
|
setBaseThumbnails(thumbs);
|
||||||
|
setReorderThumbnails(thumbs);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (!cancelled) {
|
||||||
|
setError('Failed to generate thumbnails (see console).');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [pdf]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pdf) {
|
||||||
|
setReorderThumbnails(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const thumbs = await generateThumbnailsWithRotations(
|
||||||
|
pdf.arrayBuffer,
|
||||||
|
rotations
|
||||||
|
);
|
||||||
|
if (!cancelled) {
|
||||||
|
setReorderThumbnails(thumbs);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (!cancelled) {
|
||||||
|
setError('Failed to generate rotated thumbnails (see console).');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [pdf, rotations]);
|
||||||
|
|
||||||
|
const togglePageSelection = (index: number) => {
|
||||||
|
setSelectedPages((prev) =>
|
||||||
|
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSplit = async () => {
|
||||||
|
if (!pdf) return;
|
||||||
|
setError(null);
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await splitIntoSinglePages(pdf);
|
||||||
|
setSplitResults(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('Error while splitting PDF (see console).');
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExtractRange = async (from: number, to: number) => {
|
||||||
|
if (!pdf) return;
|
||||||
|
setError(null);
|
||||||
|
setIsBusy(true);
|
||||||
|
|
||||||
|
if (rangeUrl) {
|
||||||
|
URL.revokeObjectURL(rangeUrl);
|
||||||
|
setRangeUrl(null);
|
||||||
|
setRangeFilename(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await extractRange(pdf, { from, to });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const base = pdf.name.replace(/\.pdf$/i, '');
|
||||||
|
const filename = `${base}_pages_${from}-${to}.pdf`;
|
||||||
|
setRangeUrl(url);
|
||||||
|
setRangeFilename(filename);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('Error while extracting range (see console).');
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportReordered = async (order: number[]) => {
|
||||||
|
if (!pdf) return;
|
||||||
|
setError(null);
|
||||||
|
setIsBusy(true);
|
||||||
|
|
||||||
|
if (reorderUrl) {
|
||||||
|
URL.revokeObjectURL(reorderUrl);
|
||||||
|
setReorderUrl(null);
|
||||||
|
setReorderFilename(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await exportReordered(pdf, order, rotations);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const base = pdf.name.replace(/\.pdf$/i, '');
|
||||||
|
const filename = `${base}_reordered.pdf`;
|
||||||
|
setReorderUrl(url);
|
||||||
|
setReorderFilename(filename);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('Error while exporting reordered PDF (see console).');
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRotatePage = (pageIndex: number) => {
|
||||||
|
setRotations((prev) => {
|
||||||
|
const current = prev[pageIndex] ?? 0;
|
||||||
|
const next = (current + 90) % 360;
|
||||||
|
return { ...prev, [pageIndex]: next };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPdf = !!pdf;
|
||||||
|
const pageCount = pdf?.pageCount ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout activeTool={activeTool} onToolChange={setActiveTool}>
|
||||||
|
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
||||||
|
|
||||||
|
{activeTool === 'split' && pdf && (
|
||||||
|
<>
|
||||||
|
<PageList
|
||||||
|
pageCount={pageCount}
|
||||||
|
selectedPages={selectedPages}
|
||||||
|
onTogglePage={togglePageSelection}
|
||||||
|
thumbnails={baseThumbnails}
|
||||||
|
/>
|
||||||
|
<ActionsPanel
|
||||||
|
hasPdf={hasPdf}
|
||||||
|
onSplit={handleSplit}
|
||||||
|
onExtractRange={handleExtractRange}
|
||||||
|
isBusy={isBusy}
|
||||||
|
splitResults={splitResults}
|
||||||
|
rangeDownloadUrl={rangeUrl}
|
||||||
|
rangeFilename={rangeFilename}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTool === 'reorder' && (
|
||||||
|
<ReorderPanel
|
||||||
|
pageCount={pageCount}
|
||||||
|
thumbnails={reorderThumbnails}
|
||||||
|
isBusy={isBusy}
|
||||||
|
hasPdf={hasPdf}
|
||||||
|
rotations={rotations}
|
||||||
|
onRotate={handleRotatePage}
|
||||||
|
onExportReordered={handleExportReordered}
|
||||||
|
reorderDownloadUrl={reorderUrl}
|
||||||
|
reorderFilename={reorderFilename}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{ border: '1px solid #fecaca', background: '#fef2f2' }}
|
||||||
|
>
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
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;
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
140
src/pdf/pdfService.ts
Normal file
140
src/pdf/pdfService.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { PDFDocument, degrees } from 'pdf-lib';
|
||||||
|
import type { PdfFile, SplitResult, Range } from './pdfTypes';
|
||||||
|
|
||||||
|
function createId() {
|
||||||
|
return Math.random().toString(36).slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPdfFromFile(file: File): Promise<PdfFile> {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const doc = await PDFDocument.load(arrayBuffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: createId(),
|
||||||
|
name: file.name,
|
||||||
|
doc,
|
||||||
|
pageCount: doc.getPageCount(),
|
||||||
|
arrayBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function splitIntoSinglePages(
|
||||||
|
pdf: PdfFile
|
||||||
|
): Promise<SplitResult[]> {
|
||||||
|
const { doc, name } = pdf;
|
||||||
|
|
||||||
|
const title = doc.getTitle();
|
||||||
|
const author = doc.getAuthor();
|
||||||
|
const subject = doc.getSubject();
|
||||||
|
const keywords = doc.getKeywords();
|
||||||
|
const producer = doc.getProducer();
|
||||||
|
const creator = doc.getCreator();
|
||||||
|
const creationDate = doc.getCreationDate();
|
||||||
|
const modificationDate = doc.getModificationDate();
|
||||||
|
|
||||||
|
const results: SplitResult[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < doc.getPageCount(); i++) {
|
||||||
|
const newDoc = await PDFDocument.create();
|
||||||
|
const [copiedPage] = await newDoc.copyPages(doc, [i]);
|
||||||
|
newDoc.addPage(copiedPage);
|
||||||
|
|
||||||
|
if (title) newDoc.setTitle(title);
|
||||||
|
if (author) newDoc.setAuthor(author);
|
||||||
|
if (subject) newDoc.setSubject(subject);
|
||||||
|
if (keywords) newDoc.setKeywords(keywords);
|
||||||
|
if (producer) newDoc.setProducer(producer);
|
||||||
|
if (creator) newDoc.setCreator(creator);
|
||||||
|
if (creationDate) newDoc.setCreationDate(creationDate);
|
||||||
|
if (modificationDate) newDoc.setModificationDate(modificationDate);
|
||||||
|
|
||||||
|
const bytes = await newDoc.save();
|
||||||
|
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||||
|
|
||||||
|
const base = name.replace(/\.pdf$/i, '');
|
||||||
|
const filename = `${base}_page_${String(i + 1).padStart(3, '0')}.pdf`;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
pageIndex: i,
|
||||||
|
blob,
|
||||||
|
filename,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractRange(
|
||||||
|
pdf: PdfFile,
|
||||||
|
range: Range
|
||||||
|
): Promise<Blob> {
|
||||||
|
const { doc } = pdf;
|
||||||
|
const pageCount = doc.getPageCount();
|
||||||
|
|
||||||
|
const fromIndex = Math.max(0, range.from - 1);
|
||||||
|
const toIndex = Math.min(pageCount - 1, range.to - 1);
|
||||||
|
|
||||||
|
if (fromIndex > toIndex) {
|
||||||
|
throw new Error('Invalid range: from > to');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDoc = await PDFDocument.create();
|
||||||
|
const indices: number[] = [];
|
||||||
|
for (let i = fromIndex; i <= toIndex; i++) indices.push(i);
|
||||||
|
|
||||||
|
const copiedPages = await newDoc.copyPages(doc, indices);
|
||||||
|
copiedPages.forEach((p) => newDoc.addPage(p));
|
||||||
|
|
||||||
|
const bytes = await newDoc.save();
|
||||||
|
return new Blob([bytes], { type: 'application/pdf' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
|
||||||
|
const newDoc = await PDFDocument.create();
|
||||||
|
|
||||||
|
for (const pdf of pdfs) {
|
||||||
|
const pageCount = pdf.doc.getPageCount();
|
||||||
|
const indices = Array.from({ length: pageCount }, (_, i) => i);
|
||||||
|
const copiedPages = await newDoc.copyPages(pdf.doc, indices);
|
||||||
|
copiedPages.forEach((p) => newDoc.addPage(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await newDoc.save();
|
||||||
|
return new Blob([bytes], { type: 'application/pdf' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportReordered(
|
||||||
|
pdf: PdfFile,
|
||||||
|
order: number[],
|
||||||
|
rotations?: Record<number, number>
|
||||||
|
): Promise<Blob> {
|
||||||
|
const { doc } = pdf;
|
||||||
|
const pageCount = doc.getPageCount();
|
||||||
|
|
||||||
|
if (order.length === 0) {
|
||||||
|
throw new Error('Order must contain at least one page');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.some((i) => i < 0 || i >= pageCount)) {
|
||||||
|
throw new Error('Order contains invalid page indices');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDoc = await PDFDocument.create();
|
||||||
|
const indices = [...order];
|
||||||
|
|
||||||
|
const copiedPages = await newDoc.copyPages(doc, indices);
|
||||||
|
|
||||||
|
copiedPages.forEach((page, idx) => {
|
||||||
|
const originalIndex = indices[idx];
|
||||||
|
const angle = rotations?.[originalIndex];
|
||||||
|
|
||||||
|
if (typeof angle === 'number' && angle % 360 !== 0) {
|
||||||
|
page.setRotation(degrees(angle));
|
||||||
|
}
|
||||||
|
|
||||||
|
newDoc.addPage(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
const bytes = await newDoc.save();
|
||||||
|
return new Blob([bytes], { type: 'application/pdf' });
|
||||||
|
}
|
||||||
108
src/pdf/pdfThumbnailService.ts
Normal file
108
src/pdf/pdfThumbnailService.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||||
|
|
||||||
|
type RotationsMap = Record<number, number>;
|
||||||
|
|
||||||
|
export async function generateThumbnails(
|
||||||
|
arrayBuffer: ArrayBuffer,
|
||||||
|
maxHeight = 150
|
||||||
|
): Promise<string[]> {
|
||||||
|
return generateThumbnailsInternal(arrayBuffer, {}, maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateThumbnailsWithRotations(
|
||||||
|
arrayBuffer: ArrayBuffer,
|
||||||
|
rotations: RotationsMap,
|
||||||
|
maxHeight = 150
|
||||||
|
): Promise<string[]> {
|
||||||
|
return generateThumbnailsInternal(arrayBuffer, rotations, maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateThumbnailsInternal(
|
||||||
|
arrayBuffer: ArrayBuffer,
|
||||||
|
rotations: RotationsMap,
|
||||||
|
maxHeight: number
|
||||||
|
): Promise<string[]> {
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||||
|
const pdf = await loadingTask.promise;
|
||||||
|
|
||||||
|
const thumbs: string[] = [];
|
||||||
|
|
||||||
|
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
|
||||||
|
const page = await pdf.getPage(pageNum);
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const scale = maxHeight / viewport.height;
|
||||||
|
const scaledViewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const baseCanvas = document.createElement('canvas');
|
||||||
|
const baseCtx = baseCanvas.getContext('2d');
|
||||||
|
if (!baseCtx) {
|
||||||
|
thumbs.push('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseCanvas.width = scaledViewport.width;
|
||||||
|
baseCanvas.height = scaledViewport.height;
|
||||||
|
|
||||||
|
const renderTask = page.render({
|
||||||
|
canvasContext: baseCtx,
|
||||||
|
viewport: scaledViewport,
|
||||||
|
});
|
||||||
|
await renderTask.promise;
|
||||||
|
|
||||||
|
const originalIndex = pageNum - 1;
|
||||||
|
const rotationDegRaw = rotations[originalIndex] ?? 0;
|
||||||
|
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360;
|
||||||
|
|
||||||
|
if (rotationDeg === 0) {
|
||||||
|
thumbs.push(baseCanvas.toDataURL('image/png'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotatedCanvas = document.createElement('canvas');
|
||||||
|
const rotatedCtx = rotatedCanvas.getContext('2d');
|
||||||
|
if (!rotatedCtx) {
|
||||||
|
thumbs.push(baseCanvas.toDataURL('image/png'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rad = (rotationDeg * Math.PI) / 180;
|
||||||
|
|
||||||
|
if (rotationDeg === 90 || rotationDeg === 270) {
|
||||||
|
rotatedCanvas.width = baseCanvas.height;
|
||||||
|
rotatedCanvas.height = baseCanvas.width;
|
||||||
|
} else {
|
||||||
|
rotatedCanvas.width = baseCanvas.width;
|
||||||
|
rotatedCanvas.height = baseCanvas.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotatedCtx.save();
|
||||||
|
|
||||||
|
switch (rotationDeg) {
|
||||||
|
case 90:
|
||||||
|
rotatedCtx.translate(rotatedCanvas.width, 0);
|
||||||
|
rotatedCtx.rotate(rad);
|
||||||
|
break;
|
||||||
|
case 180:
|
||||||
|
rotatedCtx.translate(rotatedCanvas.width, rotatedCanvas.height);
|
||||||
|
rotatedCtx.rotate(rad);
|
||||||
|
break;
|
||||||
|
case 270:
|
||||||
|
rotatedCtx.translate(0, rotatedCanvas.height);
|
||||||
|
rotatedCtx.rotate(rad);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotatedCtx.drawImage(baseCanvas, 0, 0);
|
||||||
|
rotatedCtx.restore();
|
||||||
|
|
||||||
|
thumbs.push(rotatedCanvas.toDataURL('image/png'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return thumbs;
|
||||||
|
}
|
||||||
20
src/pdf/pdfTypes.ts
Normal file
20
src/pdf/pdfTypes.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
export interface PdfFile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
doc: PDFDocument;
|
||||||
|
pageCount: number;
|
||||||
|
arrayBuffer: ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitResult {
|
||||||
|
pageIndex: number;
|
||||||
|
blob: Blob;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Range {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
151
src/styles.css
Normal file
151
src/styles.css
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
|
sans-serif;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
background: #111827;
|
||||||
|
color: #e5e7eb;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar h1 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar small {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #d1d5db;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button.active {
|
||||||
|
background: #374151;
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #111827;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-pill {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-pill.selected {
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
6
vite.config.ts
Normal file
6
vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react-swc';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user