Initial commit with Phase 0 Scaffolding
Some checks failed
Automated Container Build / build-and-push (push) Failing after 3s
CI / Backend (Python) (push) Failing after 28s
CI / Frontend (TypeScript) (push) Failing after 5m8s

This commit is contained in:
Elijah 2026-06-10 18:28:17 -07:00
parent b393607602
commit c545d4b17d
51 changed files with 7064 additions and 4 deletions

23
frontend/src/app/App.tsx Normal file
View 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
View 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);
}

View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom'