Initial commit with Phase 0 Scaffolding
This commit is contained in:
parent
b393607602
commit
c545d4b17d
51 changed files with 7064 additions and 4 deletions
23
frontend/src/app/App.tsx
Normal file
23
frontend/src/app/App.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { LoginPage } from '../pages/LoginPage'
|
||||
import { HomePage } from '../pages/HomePage'
|
||||
import { EditorPage } from '../pages/EditorPage'
|
||||
|
||||
/**
|
||||
* Root application component with route definitions.
|
||||
*
|
||||
* Routes:
|
||||
* - /login — password entry / first-run setup
|
||||
* - / — home page (recently edited, library, trash)
|
||||
* - /editor/:id — PDF editor workspace
|
||||
*/
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/editor/:id" element={<EditorPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
106
frontend/src/index.css
Normal file
106
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* PaperJet Design Tokens
|
||||
*
|
||||
* Sejda-inspired: bright, airy, light mode with generous whitespace,
|
||||
* a restrained palette (one accent), soft shadows, rounded corners.
|
||||
*/
|
||||
@theme {
|
||||
/* Accent color — refined blue */
|
||||
--color-accent-50: oklch(0.97 0.02 250);
|
||||
--color-accent-100: oklch(0.93 0.04 250);
|
||||
--color-accent-200: oklch(0.87 0.08 250);
|
||||
--color-accent-300: oklch(0.78 0.12 250);
|
||||
--color-accent-400: oklch(0.68 0.16 250);
|
||||
--color-accent-500: oklch(0.58 0.19 250);
|
||||
--color-accent-600: oklch(0.50 0.19 250);
|
||||
--color-accent-700: oklch(0.43 0.17 250);
|
||||
--color-accent-800: oklch(0.37 0.14 250);
|
||||
--color-accent-900: oklch(0.30 0.10 250);
|
||||
|
||||
/* Neutrals — warm gray */
|
||||
--color-neutral-50: oklch(0.985 0.002 250);
|
||||
--color-neutral-100: oklch(0.965 0.004 250);
|
||||
--color-neutral-200: oklch(0.925 0.006 250);
|
||||
--color-neutral-300: oklch(0.870 0.008 250);
|
||||
--color-neutral-400: oklch(0.700 0.010 250);
|
||||
--color-neutral-500: oklch(0.550 0.012 250);
|
||||
--color-neutral-600: oklch(0.440 0.012 250);
|
||||
--color-neutral-700: oklch(0.370 0.012 250);
|
||||
--color-neutral-800: oklch(0.270 0.010 250);
|
||||
--color-neutral-900: oklch(0.180 0.008 250);
|
||||
|
||||
/* Semantic */
|
||||
--color-success: oklch(0.60 0.16 145);
|
||||
--color-warning: oklch(0.75 0.16 65);
|
||||
--color-error: oklch(0.58 0.20 25);
|
||||
|
||||
/* Spacing scale */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.06), 0 2px 4px -2px oklch(0 0 0 / 0.04);
|
||||
--shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.06), 0 4px 6px -4px oklch(0 0 0 / 0.04);
|
||||
--shadow-xl: 0 20px 25px -5px oklch(0 0 0 / 0.08), 0 8px 10px -6px oklch(0 0 0 / 0.04);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
/* Transitions */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 250ms;
|
||||
}
|
||||
|
||||
/* --- Base styles --- */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-neutral-50);
|
||||
color: var(--color-neutral-800);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Focus ring utility */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background-color: var(--color-accent-200);
|
||||
color: var(--color-accent-900);
|
||||
}
|
||||
159
frontend/src/lib/api/client.ts
Normal file
159
frontend/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Shared API client for PaperJet backend.
|
||||
*
|
||||
* All API calls go through this module. It handles:
|
||||
* - Base URL prefixing (/api/v1)
|
||||
* - CSRF protection (X-Requested-With header on mutating requests)
|
||||
* - Consistent error handling with the { error: { code, message } } envelope
|
||||
* - 401 → redirect to login
|
||||
*/
|
||||
|
||||
const BASE_URL = '/api/v1'
|
||||
|
||||
/** Error shape returned by the backend */
|
||||
export interface ApiError {
|
||||
code: string
|
||||
message: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
/** Custom error class for API failures */
|
||||
export class ApiRequestError extends Error {
|
||||
status: number
|
||||
error: ApiError
|
||||
|
||||
constructor(status: number, error: ApiError) {
|
||||
super(error.message)
|
||||
this.name = 'ApiRequestError'
|
||||
this.status = status
|
||||
this.error = error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal fetch wrapper with shared behavior.
|
||||
*/
|
||||
async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const url = `${BASE_URL}${path}`
|
||||
|
||||
const headers = new Headers(options.headers)
|
||||
|
||||
// CSRF: add X-Requested-With on all mutating requests
|
||||
const method = (options.method ?? 'GET').toUpperCase()
|
||||
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
||||
headers.set('X-Requested-With', 'XMLHttpRequest')
|
||||
}
|
||||
|
||||
// Default to JSON content type for non-FormData bodies
|
||||
if (options.body && !(options.body instanceof FormData)) {
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
// Handle 401 — redirect to login
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/login'
|
||||
throw new ApiRequestError(401, {
|
||||
code: 'unauthorized',
|
||||
message: 'Session expired',
|
||||
})
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
// Parse response
|
||||
const data = await response.json()
|
||||
|
||||
// Handle error responses
|
||||
if (!response.ok) {
|
||||
const error: ApiError = data.error ?? {
|
||||
code: 'unknown',
|
||||
message: response.statusText,
|
||||
}
|
||||
throw new ApiRequestError(response.status, error)
|
||||
}
|
||||
|
||||
return data as T
|
||||
}
|
||||
|
||||
// --- Convenience methods ---
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => apiFetch<T>(path),
|
||||
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
apiFetch<T>(path, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
apiFetch<T>(path, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
apiFetch<T>(path, {
|
||||
method: 'PATCH',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
delete: <T>(path: string) =>
|
||||
apiFetch<T>(path, { method: 'DELETE' }),
|
||||
|
||||
/** POST with FormData (multipart — for file uploads) */
|
||||
upload: <T>(path: string, formData: FormData) =>
|
||||
apiFetch<T>(path, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
// Don't set Content-Type — browser sets it with boundary
|
||||
}),
|
||||
|
||||
/**
|
||||
* Fetch a binary response (e.g., PDF export).
|
||||
* Returns the raw Response for streaming.
|
||||
*/
|
||||
fetchBlob: async (path: string, options?: RequestInit): Promise<Blob> => {
|
||||
const url = `${BASE_URL}${path}`
|
||||
const headers = new Headers(options?.headers)
|
||||
const method = (options?.method ?? 'GET').toUpperCase()
|
||||
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
||||
headers.set('X-Requested-With', 'XMLHttpRequest')
|
||||
}
|
||||
if (options?.body && !(options.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/login'
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new ApiRequestError(response.status, data.error)
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
},
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { App } from './app/App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
49
frontend/src/pages/EditorPage.tsx
Normal file
49
frontend/src/pages/EditorPage.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useParams } from 'react-router-dom'
|
||||
|
||||
/**
|
||||
* Editor page — PDF canvas workspace.
|
||||
* Full implementation begins in Phase 2 (rendering) and Phase 3 (annotations).
|
||||
*/
|
||||
export function EditorPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-neutral-100">
|
||||
{/* Editor toolbar */}
|
||||
<header className="flex items-center justify-between border-b border-neutral-200 bg-white px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100
|
||||
hover:text-neutral-700"
|
||||
>
|
||||
← Back
|
||||
</a>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
Document {id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-neutral-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
Saved
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white
|
||||
transition-colors hover:bg-accent-600"
|
||||
>
|
||||
Export PDF
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Canvas area */}
|
||||
<main className="flex flex-1 items-center justify-center overflow-auto">
|
||||
<div className="text-sm text-neutral-400">
|
||||
PDF viewer will render here (Phase 2)
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
frontend/src/pages/HomePage.tsx
Normal file
61
frontend/src/pages/HomePage.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Home page — recently edited, library, and trash tabs.
|
||||
* Full implementation in Phase 1.
|
||||
*/
|
||||
export function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Header */}
|
||||
<header className="border-b border-neutral-200 bg-white">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<h1 className="text-xl font-semibold text-neutral-900">PaperJet</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white
|
||||
transition-colors duration-150 hover:bg-accent-600"
|
||||
>
|
||||
Upload PDF
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="mx-auto max-w-7xl px-6 py-8">
|
||||
{/* Tab navigation */}
|
||||
<nav className="mb-8 flex gap-1 rounded-lg bg-neutral-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-neutral-900
|
||||
shadow-sm transition-colors"
|
||||
>
|
||||
Recently Edited
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-neutral-500
|
||||
transition-colors hover:text-neutral-700"
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-neutral-500
|
||||
transition-colors hover:text-neutral-700"
|
||||
>
|
||||
Trash
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Empty state placeholder */}
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed
|
||||
border-neutral-300 py-20 text-center">
|
||||
<div className="mb-4 text-5xl">📄</div>
|
||||
<h2 className="text-lg font-medium text-neutral-700">No documents yet</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Upload a PDF to get started
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
frontend/src/pages/LoginPage.tsx
Normal file
40
frontend/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Login page — handles both first-run password setup and normal login.
|
||||
* Full implementation in Phase 1.
|
||||
*/
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-8 shadow-lg">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">PaperJet</h1>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Self-hosted PDF editor
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-neutral-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm
|
||||
placeholder:text-neutral-400 focus:border-accent-500 focus:outline-none
|
||||
focus:ring-2 focus:ring-accent-200"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-white
|
||||
transition-colors duration-150 hover:bg-accent-600 active:bg-accent-700"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
frontend/src/test-setup.ts
Normal file
1
frontend/src/test-setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom'
|
||||
Loading…
Add table
Add a link
Reference in a new issue