commit baacb7cface81b108643a7d63a7bc8f4bb43bb53 Author: zemion Date: Wed Nov 26 18:02:58 2025 +0100 first commit diff --git a/index.html b/index.html new file mode 100644 index 0000000..c4d8dc6 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + Self-hosted PDF Workbench + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c47590 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..ce4142b --- /dev/null +++ b/src/App.tsx @@ -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('split'); + const [pdf, setPdf] = useState(null); + const [selectedPages, setSelectedPages] = useState([]); + const [isBusy, setIsBusy] = useState(false); + const [splitResults, setSplitResults] = useState([]); + const [error, setError] = useState(null); + + const [baseThumbnails, setBaseThumbnails] = useState(null); + const [reorderThumbnails, setReorderThumbnails] = useState( + null + ); + + const [rangeUrl, setRangeUrl] = useState(null); + const [rangeFilename, setRangeFilename] = useState(null); + + const [reorderUrl, setReorderUrl] = useState(null); + const [reorderFilename, setReorderFilename] = useState(null); + + const [rotations, setRotations] = useState>({}); + + 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 ( + + + + {activeTool === 'split' && pdf && ( + <> + + + + )} + + {activeTool === 'reorder' && ( + + )} + + {error && ( +
+ Error: {error} +
+ )} +
+ ); +}; + +export default App; diff --git a/src/components/ActionsPanel.tsx b/src/components/ActionsPanel.tsx new file mode 100644 index 0000000..507b1fa --- /dev/null +++ b/src/components/ActionsPanel.tsx @@ -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 = ({ + hasPdf, + onSplit, + onExtractRange, + isBusy, + splitResults, + rangeDownloadUrl, + rangeFilename, +}) => { + const [fromPage, setFromPage] = useState(''); + const [toPage, setToPage] = useState(''); + + const handleExtractClick = () => { + const from = parseInt(fromPage, 10); + const to = parseInt(toPage, 10); + if (!Number.isFinite(from) || !Number.isFinite(to)) return; + onExtractRange(from, to); + }; + + return ( +
+

3. Actions

+

Split into single pages or extract a continuous range.

+ +
+ +
+ +
+ +
+
+ Extract range: +
+
+ + + +
+ + {rangeDownloadUrl && rangeFilename && ( +
+ Range result:{' '} + + Download {rangeFilename} + +
+ )} +
+ + {splitResults.length > 0 && ( +
+ Split result: +
+ {splitResults.map((r) => { + const url = URL.createObjectURL(r.blob); + return ( + { + setTimeout(() => URL.revokeObjectURL(url), 5000); + }} + > + Download {r.filename} + + ); + })} +
+
+ )} +
+ ); +}; + +export default ActionsPanel; diff --git a/src/components/FileLoader.tsx b/src/components/FileLoader.tsx new file mode 100644 index 0000000..08d2af8 --- /dev/null +++ b/src/components/FileLoader.tsx @@ -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 = ({ pdf, onFileLoaded }) => { + const handleChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + onFileLoaded(file); + e.target.value = ''; + } + }; + + return ( +
+

1. Load PDF

+

Select a PDF file. Processing happens entirely in your browser.

+ + + {pdf && ( +
+
+ Loaded: {pdf.name} +
+
+ Pages: {pdf.pageCount} +
+
+ )} +
+ ); +}; + +export default FileLoader; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..9dca2d0 --- /dev/null +++ b/src/components/Layout.tsx @@ -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 = ({ activeTool, onToolChange, children }) => { + return ( +
+ + +
{children}
+
+ ); +}; + +export default Layout; diff --git a/src/components/PageList.tsx b/src/components/PageList.tsx new file mode 100644 index 0000000..c535998 --- /dev/null +++ b/src/components/PageList.tsx @@ -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 = ({ + pageCount, + selectedPages, + onTogglePage, + thumbnails, +}) => { + if (pageCount === 0) return null; + + const pages = Array.from({ length: pageCount }, (_, i) => i); + + return ( +
+

2. Pages

+

+ Thumbnails are generated in your browser. Click to select pages (used by + future tools). +

+
+ {pages.map((i) => { + const selected = selectedPages.includes(i); + const thumb = thumbnails?.[i]; + + return ( + + ); + })} +
+
+ ); +}; + +export default PageList; diff --git a/src/components/ReorderPanel.tsx b/src/components/ReorderPanel.tsx new file mode 100644 index 0000000..bad9a02 --- /dev/null +++ b/src/components/ReorderPanel.tsx @@ -0,0 +1,225 @@ +import React, { useEffect, useState } from 'react'; + +interface ReorderPanelProps { + pageCount: number; + thumbnails: string[] | null; + isBusy: boolean; + hasPdf: boolean; + rotations: Record; + onRotate: (pageIndex: number) => void; + onExportReordered: (order: number[]) => void; + reorderDownloadUrl: string | null; + reorderFilename: string | null; +} + +const ReorderPanel: React.FC = ({ + pageCount, + thumbnails, + isBusy, + hasPdf, + rotations, + onRotate, + onExportReordered, + reorderDownloadUrl, + reorderFilename, +}) => { + const [order, setOrder] = useState([]); + const [draggingIndex, setDraggingIndex] = useState(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 ( +
+

Reorder pages

+

Load a PDF first to reorder, delete, or rotate its pages.

+
+ ); + } + + return ( +
+

Reorder / delete / rotate

+

+ Drag pages to reorder them. Use rotate and delete controls below each + thumbnail. All changes stay in memory until you export a new PDF. +

+ +
+ {order.map((pageIndex, visualIndex) => { + const thumb = thumbnails?.[pageIndex]; + const isDragging = visualIndex === draggingIndex; + const rotation = rotations[pageIndex] ?? 0; + + return ( +
+ {thumb ? ( + {`Page + ) : ( +
+ )} + Page {pageIndex + 1} + + Pos {visualIndex + 1} · Rot {rotation}° + + +
+ + +
+
+ ); + })} +
+ +
+ +
+ + {reorderDownloadUrl && reorderFilename && ( +
+ Reordered result:{' '} + + Download {reorderFilename} + +
+ )} +
+ ); +}; + +export default ReorderPanel; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..5610739 --- /dev/null +++ b/src/main.tsx @@ -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( + + + +); diff --git a/src/pdf/pdfService.ts b/src/pdf/pdfService.ts new file mode 100644 index 0000000..04e35ec --- /dev/null +++ b/src/pdf/pdfService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 +): Promise { + 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' }); +} diff --git a/src/pdf/pdfThumbnailService.ts b/src/pdf/pdfThumbnailService.ts new file mode 100644 index 0000000..6e57fa7 --- /dev/null +++ b/src/pdf/pdfThumbnailService.ts @@ -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; + +export async function generateThumbnails( + arrayBuffer: ArrayBuffer, + maxHeight = 150 +): Promise { + return generateThumbnailsInternal(arrayBuffer, {}, maxHeight); +} + +export async function generateThumbnailsWithRotations( + arrayBuffer: ArrayBuffer, + rotations: RotationsMap, + maxHeight = 150 +): Promise { + return generateThumbnailsInternal(arrayBuffer, rotations, maxHeight); +} + +async function generateThumbnailsInternal( + arrayBuffer: ArrayBuffer, + rotations: RotationsMap, + maxHeight: number +): Promise { + 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; +} diff --git a/src/pdf/pdfTypes.ts b/src/pdf/pdfTypes.ts new file mode 100644 index 0000000..f22eaf9 --- /dev/null +++ b/src/pdf/pdfTypes.ts @@ -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; +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..9ec1d5a --- /dev/null +++ b/src/styles.css @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c94da42 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..4f312ac --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; + +export default defineConfig({ + plugins: [react()], +});