Fix bugs, convert thumbnail to png, add context menu
Some checks failed
Automated Container Build / build-and-push (push) Failing after 2s
CI / Backend (Python) (push) Failing after 9s
CI / Frontend (TypeScript) (push) Failing after 4m46s

This commit is contained in:
Elijah 2026-06-10 19:06:28 -07:00
parent c545d4b17d
commit eb8a302222
37 changed files with 1916 additions and 137 deletions

View file

@ -1,14 +1,15 @@
{
"name": "frontend",
"version": "0.0.0",
"name": "paperjet",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.0.0",
"name": "paperjet",
"version": "0.1.0",
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"date-fns": "^4.4.0",
"fabric": "^7.4.0",
"pdfjs-dist": "^6.0.227",
"react": "^19.2.6",
@ -22,7 +23,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/fabric": "^5.3.11",
"@types/node": "^24.12.3",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@ -2598,6 +2599,16 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -3508,7 +3519,6 @@
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",

View file

@ -14,6 +14,7 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"date-fns": "^4.4.0",
"fabric": "^7.4.0",
"pdfjs-dist": "^6.0.227",
"react": "^19.2.6",
@ -27,7 +28,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/fabric": "^5.3.11",
"@types/node": "^24.12.3",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",

View file

@ -1,22 +1,36 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { LoginPage } from '../pages/LoginPage'
import { SetupPage } from '../pages/SetupPage'
import { HomePage } from '../pages/HomePage'
import { EditorPage } from '../pages/EditorPage'
import { AuthGuard } from '../features/auth/AuthGuard'
/**
* 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="/login" element={
<AuthGuard>
<LoginPage />
</AuthGuard>
} />
<Route path="/setup" element={
<AuthGuard>
<SetupPage />
</AuthGuard>
} />
{/* Protected Routes */}
<Route path="/" element={
<AuthGuard>
<HomePage />
</AuthGuard>
} />
<Route path="/editor/:id" element={
<AuthGuard>
<EditorPage />
</AuthGuard>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View file

@ -0,0 +1,32 @@
import { useEffect } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from './useAuth'
export const AuthGuard = ({ children }: { children: React.ReactNode }) => {
const { isInitialized, loggedIn, setupRequired, initialize } = useAuth()
const location = useLocation()
useEffect(() => {
if (!isInitialized) {
initialize()
}
}, [isInitialized, initialize])
if (!isInitialized) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
)
}
if (setupRequired && location.pathname !== '/setup') {
return <Navigate to="/setup" replace />
}
if (!loggedIn && !setupRequired && location.pathname !== '/login') {
return <Navigate to="/login" replace />
}
return <>{children}</>
}

View file

@ -0,0 +1,72 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from './useAuth'
export const LoginForm = () => {
const [password, setPassword] = useState('')
const { login, error, isLoading } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!password) return
try {
await login({ password })
navigate('/')
} catch (err) {
// Error is handled by the store
}
}
return (
<div className="w-full max-w-md p-8 space-y-8 bg-white/70 dark:bg-gray-800/70 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-100 dark:border-gray-700 transition-all">
<div className="text-center">
<h2 className="text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white mb-2">
Welcome back
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Enter your master password to unlock your library
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Password
</label>
<input
type="password"
required
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white/50 dark:bg-gray-700/50 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all placeholder-gray-400 dark:placeholder-gray-500"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded-lg animate-pulse">
{error}
</div>
)}
<button
type="submit"
disabled={isLoading || !password}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-[0.98]"
>
{isLoading ? (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
'Unlock Library'
)}
</button>
</form>
</div>
)
}

View file

@ -0,0 +1,107 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from './useAuth'
export const SetupForm = () => {
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [localError, setLocalError] = useState('')
const { setup, error, isLoading } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLocalError('')
if (password.length < 8) {
setLocalError('Password must be at least 8 characters long')
return
}
if (password !== confirmPassword) {
setLocalError('Passwords do not match')
return
}
try {
await setup({ password })
navigate('/')
} catch (err) {
// Error is handled by the store
}
}
const displayError = localError || error
return (
<div className="w-full max-w-md p-8 space-y-8 bg-white/70 dark:bg-gray-800/70 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-100 dark:border-gray-700 transition-all">
<div className="text-center">
<div className="mx-auto w-16 h-16 bg-indigo-100 dark:bg-indigo-900/50 rounded-full flex items-center justify-center mb-6">
<svg className="w-8 h-8 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h2 className="text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white mb-2">
Set up PaperJet
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Create a master password to secure your documents. Make sure you remember it!
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Master Password
</label>
<input
type="password"
required
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white/50 dark:bg-gray-700/50 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Confirm Password
</label>
<input
type="password"
required
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white/50 dark:bg-gray-700/50 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
/>
</div>
</div>
{displayError && (
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded-lg animate-pulse">
{displayError}
</div>
)}
<button
type="submit"
disabled={isLoading || !password || !confirmPassword}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-[0.98]"
>
{isLoading ? (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
'Complete Setup'
)}
</button>
</form>
</div>
)
}

View file

@ -0,0 +1,21 @@
import { api } from '@/lib/api/client'
import type { LoginCredentials, SetupCredentials } from './types'
export interface AuthStatus {
setupRequired: boolean
loggedIn: boolean
}
export const authApi = {
/** Check if setup is required and if user is logged in */
getStatus: () => api.get<AuthStatus>('/auth/status'),
/** Initial setup to create the password */
setup: (data: SetupCredentials) => api.post<{ status: string }>('/auth/setup', data),
/** Login to get a session cookie */
login: (data: LoginCredentials) => api.post<{ status: string }>('/auth/login', data),
/** Logout and clear session */
logout: () => api.post<{ status: string }>('/auth/logout'),
}

View file

@ -0,0 +1,7 @@
export interface LoginCredentials {
password: string
}
export interface SetupCredentials {
password: string
}

View file

@ -0,0 +1,88 @@
import { create } from 'zustand'
import { authApi } from './api'
import type { LoginCredentials, SetupCredentials } from './types'
interface AuthState {
isInitialized: boolean
setupRequired: boolean
loggedIn: boolean
isLoading: boolean
error: string | null
initialize: () => Promise<void>
login: (data: LoginCredentials) => Promise<void>
setup: (data: SetupCredentials) => Promise<void>
logout: () => Promise<void>
clearError: () => void
}
export const useAuth = create<AuthState>((set) => ({
isInitialized: false,
setupRequired: false,
loggedIn: false,
isLoading: true,
error: null,
initialize: async () => {
try {
set({ isLoading: true, error: null })
const status = await authApi.getStatus()
set({
setupRequired: status.setupRequired,
loggedIn: status.loggedIn,
isInitialized: true,
isLoading: false
})
} catch (err: any) {
set({
error: err.error?.message || err.message || 'Failed to initialize',
isLoading: false,
isInitialized: true
})
}
},
login: async (data: LoginCredentials) => {
try {
set({ isLoading: true, error: null })
await authApi.login(data)
set({ loggedIn: true, isLoading: false })
} catch (err: any) {
set({
error: err.error?.message || err.message || 'Failed to login',
isLoading: false
})
throw err
}
},
setup: async (data: SetupCredentials) => {
try {
set({ isLoading: true, error: null })
await authApi.setup(data)
set({ setupRequired: false, loggedIn: true, isLoading: false })
} catch (err: any) {
set({
error: err.error?.message || err.message || 'Failed to setup',
isLoading: false
})
throw err
}
},
logout: async () => {
try {
set({ isLoading: true, error: null })
await authApi.logout()
set({ loggedIn: false, isLoading: false })
window.location.href = '/login'
} catch (err: any) {
set({
error: err.error?.message || err.message || 'Failed to logout',
isLoading: false
})
}
},
clearError: () => set({ error: null })
}))

View file

@ -0,0 +1,98 @@
import { useEffect, useRef } from 'react'
export interface ContextMenuProps {
x: number
y: number
isTrash: boolean
onClose: () => void
onOpen: () => void
onRename: () => void
onDelete: () => void
onRestore: () => void
onHardDelete: () => void
}
export const ContextMenu = ({
x, y, isTrash, onClose, onOpen, onRename, onDelete, onRestore, onHardDelete
}: ContextMenuProps) => {
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
// Prevent scrolling while context menu is open
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
}
}, [onClose])
// Ensure menu doesn't go off screen
const safeX = Math.min(x, window.innerWidth - 200)
const safeY = Math.min(y, window.innerHeight - (isTrash ? 100 : 150))
return (
<div
ref={menuRef}
className="fixed z-50 w-48 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 py-2 overflow-hidden animate-in fade-in zoom-in duration-100"
style={{ left: safeX, top: safeY }}
onContextMenu={(e) => e.preventDefault()}
>
{!isTrash && (
<>
<button
onClick={() => { onOpen(); onClose(); }}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
Open in Editor
</button>
<button
onClick={() => { onRename(); onClose(); }}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
Rename...
</button>
<div className="h-px bg-gray-200 dark:bg-gray-700 my-1 mx-2" />
<button
onClick={() => { onDelete(); onClose(); }}
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
>
Move to Trash
</button>
</>
)}
{isTrash && (
<>
<button
onClick={() => { onRestore(); onClose(); }}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-emerald-50 dark:hover:bg-emerald-900/30 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
>
Restore Document
</button>
<div className="h-px bg-gray-200 dark:bg-gray-700 my-1 mx-2" />
<button
onClick={() => { onHardDelete(); onClose(); }}
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
>
Delete Permanently
</button>
</>
)}
</div>
)
}

View file

@ -0,0 +1,136 @@
import { useState } from 'react'
import { useLibrary } from './useLibrary'
import { libraryApi } from './api'
import { Link, useNavigate } from 'react-router-dom'
import { formatDistanceToNow } from 'date-fns'
import { ContextMenu } from './ContextMenu'
export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
const { documents, selectedIds, toggleSelection, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary()
const navigate = useNavigate()
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null)
const handleContextMenu = (e: React.MouseEvent, docId: string) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY, docId })
}
if (isLoading) {
return (
<div className="flex justify-center items-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
)
}
if (error) {
return (
<div className="flex justify-center items-center py-20 text-red-500">
{error}
</div>
)
}
if (documents.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-gray-300 dark:border-gray-700 py-24 text-center bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm transition-all hover:border-indigo-400 dark:hover:border-indigo-500 hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10">
<div className="mb-4 text-6xl opacity-50 grayscale hover:grayscale-0 transition-all duration-300">📄</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{isTrash ? 'Trash is empty' : 'No documents yet'}
</h2>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{isTrash ? 'Nothing to see here.' : 'Drag & drop a PDF, or click Upload to get started.'}
</p>
</div>
)
}
return (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{documents.map((doc) => {
const isSelected = selectedIds.has(doc.id)
const thumbnailUrl = libraryApi.getThumbnailUrl(doc.id)
return (
<div
key={doc.id}
onContextMenu={(e) => handleContextMenu(e, doc.id)}
className={`group relative flex flex-col bg-white dark:bg-gray-800 rounded-2xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 border-2 ${
isSelected ? 'border-indigo-500 shadow-md ring-2 ring-indigo-500/20' : 'border-transparent hover:border-indigo-200 dark:hover:border-indigo-800'
}`}
>
<div
className="absolute top-3 left-3 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: isSelected ? 1 : undefined }}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelection(doc.id)}
className="w-5 h-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer shadow-sm"
/>
</div>
<Link
to={`/editor/${doc.id}`}
className="relative aspect-[3/4] bg-gray-100 dark:bg-gray-900 overflow-hidden"
onClick={(e) => {
if (isTrash) e.preventDefault() // disable link in trash
}}
>
<img
src={thumbnailUrl}
alt={doc.title}
className={`w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 ${isTrash ? 'grayscale opacity-70' : ''}`}
loading="lazy"
onError={(e) => {
// Fallback if no thumbnail
(e.target as HTMLImageElement).src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%239ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>'
;(e.target as HTMLImageElement).className = 'w-1/2 h-1/2 absolute top-1/4 left-1/4 opacity-30 object-contain'
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
</Link>
<div className="p-4 flex flex-col">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white truncate" title={doc.title}>
{doc.title}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatDistanceToNow(new Date(doc.updated_at), { addSuffix: true })}
</p>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
{(doc.size_bytes / 1024 / 1024).toFixed(1)} MB
</p>
</div>
</div>
)
})}
</div>
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
isTrash={isTrash}
onClose={() => setContextMenu(null)}
onOpen={() => navigate(`/editor/${contextMenu.docId}`)}
onRename={() => {
const doc = documents.find(d => d.id === contextMenu.docId)
if (doc) {
const newTitle = window.prompt("Rename Document:", doc.title)
if (newTitle && newTitle.trim() !== "" && newTitle !== doc.title) {
updateDocument(doc.id, { title: newTitle.trim() })
}
}
}}
onDelete={() => deleteDocument(contextMenu.docId)}
onRestore={() => restoreDocument(contextMenu.docId)}
onHardDelete={() => deleteDocument(contextMenu.docId, true)}
/>
)}
</>
)
}

View file

@ -0,0 +1,169 @@
import { useLibrary } from './useLibrary'
import { useAuth } from '../auth/useAuth'
export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'library' | 'trash', onTabChange: (tab: 'library' | 'trash') => void }) => {
const { logout } = useAuth()
const {
searchQuery, setSearchQuery, fetchDocuments, fetchTrash,
selectedIds, clearSelection, selectAll, bulkDelete, bulkRestore, emptyTrash, documents
} = useLibrary()
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
if (currentTab === 'library') {
fetchDocuments(searchQuery)
}
}
const handleTabChange = (tab: 'library' | 'trash') => {
onTabChange(tab)
if (tab === 'library') {
fetchDocuments()
} else {
fetchTrash()
}
}
const isSelectionMode = selectedIds.size > 0
const allSelected = documents.length > 0 && selectedIds.size === documents.length
return (
<div className="sticky top-0 z-40 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between gap-4">
{/* Left: Branding & Tabs */}
<div className="flex items-center gap-8">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center shadow-lg shadow-indigo-500/30">
<span className="text-white font-bold text-xl leading-none">P</span>
</div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white hidden sm:block">PaperJet</h1>
</div>
<nav className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
<button
onClick={() => handleTabChange('library')}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
currentTab === 'library'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
Library
</button>
<button
onClick={() => handleTabChange('trash')}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
currentTab === 'trash'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
Trash
</button>
</nav>
</div>
{/* Right: Search & Actions */}
<div className="flex items-center gap-4 flex-1 justify-end">
{isSelectionMode ? (
<div className="flex items-center gap-3 bg-indigo-50 dark:bg-indigo-900/30 px-4 py-1.5 rounded-full border border-indigo-100 dark:border-indigo-800">
<span className="text-sm font-medium text-indigo-700 dark:text-indigo-300">
{selectedIds.size} selected
</span>
<div className="h-4 w-px bg-indigo-200 dark:bg-indigo-700"></div>
<button onClick={allSelected ? clearSelection : selectAll} className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-200 font-medium">
{allSelected ? 'Deselect All' : 'Select All'}
</button>
{currentTab === 'library' ? (
<button
onClick={() => bulkDelete()}
className="text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 font-medium px-2 py-1 hover:bg-red-50 dark:hover:bg-red-900/30 rounded"
>
Delete
</button>
) : (
<>
<button
onClick={() => bulkRestore()}
className="text-sm text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 font-medium px-2 py-1 hover:bg-green-50 dark:hover:bg-green-900/30 rounded"
>
Restore
</button>
<button
onClick={() => bulkDelete(true)}
className="text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 font-medium px-2 py-1 hover:bg-red-50 dark:hover:bg-red-900/30 rounded"
>
Delete Forever
</button>
</>
)}
</div>
) : (
<>
{currentTab === 'library' && (
<form onSubmit={handleSearch} className="relative hidden md:block max-w-md w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search documents..."
className="block w-full pl-10 pr-3 py-2 border border-gray-200 dark:border-gray-700 rounded-xl leading-5 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent sm:text-sm transition-all"
/>
</form>
)}
{currentTab === 'trash' && (
<button
onClick={() => emptyTrash()}
className="text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 transition-colors"
>
Empty Trash
</button>
)}
{currentTab === 'library' && (
<label className="cursor-pointer inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-xl shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all active:scale-95">
<span>Upload PDF</span>
<input
type="file"
accept="application/pdf"
multiple
className="hidden"
onChange={(e) => {
// We'll dispatch a custom event that UploadArea listens to, or just click its input
// For simplicity, find the hidden file input in UploadArea and click it
const uploader = document.querySelector('input[type="file"][multiple]') as HTMLInputElement
if (uploader && uploader !== e.target) {
uploader.files = e.target.files
const event = new Event('change', { bubbles: true })
uploader.dispatchEvent(event)
}
}}
/>
</label>
)}
<button
onClick={logout}
className="p-2 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
title="Logout"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</>
)}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,86 @@
import { useRef, useState } from 'react'
import { useLibrary } from './useLibrary'
export const UploadArea = ({ children }: { children: React.ReactNode }) => {
const { uploadDocument } = useLibrary()
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await processFiles(e.dataTransfer.files)
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
await processFiles(e.target.files)
// Reset input so the same file can be selected again
e.target.value = ''
}
}
const processFiles = async (files: FileList) => {
// For now, process one by one
for (let i = 0; i < files.length; i++) {
const file = files[i]
if (file.type === 'application/pdf') {
try {
await uploadDocument(file)
} catch (err) {
console.error('Failed to upload', file.name, err)
// Could show toast notification here
}
}
}
}
return (
<div
className="relative min-h-screen"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
accept="application/pdf"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
multiple
/>
{/* Invisible overlay that appears when dragging to prevent flickering */}
{isDragging && (
<div className="absolute inset-0 z-50 bg-indigo-500/10 dark:bg-indigo-900/20 backdrop-blur-sm border-4 border-dashed border-indigo-500 dark:border-indigo-400 rounded-3xl m-4 flex items-center justify-center transition-all pointer-events-none">
<div className="bg-white/90 dark:bg-gray-800/90 px-8 py-6 rounded-2xl shadow-2xl flex flex-col items-center">
<svg className="w-16 h-16 text-indigo-500 mb-4 animate-bounce" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">Drop PDFs here</h3>
<p className="text-gray-500 dark:text-gray-400 mt-2">Release to upload to your library</p>
</div>
</div>
)}
{children}
</div>
)
}

View file

@ -0,0 +1,47 @@
import { api } from '@/lib/api/client'
import type { DocumentListResponse, DocumentMeta, DocumentUpdateRequest, BulkRequest } from './types'
export const libraryApi = {
getDocuments: (query = '', skip = 0, limit = 50) => {
const params = new URLSearchParams()
if (query) params.append('query', query)
params.append('skip', skip.toString())
params.append('limit', limit.toString())
return api.get<DocumentListResponse>(`/documents?${params.toString()}`)
},
getTrash: (skip = 0, limit = 50) => {
const params = new URLSearchParams()
params.append('skip', skip.toString())
params.append('limit', limit.toString())
return api.get<DocumentListResponse>(`/documents/trash?${params.toString()}`)
},
uploadDocument: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return api.upload<DocumentMeta>('/documents', formData)
},
getDocument: (id: string) => api.get<DocumentMeta>(`/documents/${id}`),
updateDocument: (id: string, data: DocumentUpdateRequest) =>
api.patch<DocumentMeta>(`/documents/${id}`, data),
deleteDocument: (id: string, permanent = false) =>
api.delete<{ status: string }>(`/documents/${id}?permanent=${permanent}`),
restoreDocument: (id: string) =>
api.post<DocumentMeta>(`/documents/${id}/restore`),
bulkDelete: (data: BulkRequest) =>
api.post<{ status: string, count: number }>('/documents/bulk-delete', data),
bulkRestore: (data: BulkRequest) =>
api.post<{ status: string, count: number }>('/documents/bulk-restore', data),
emptyTrash: () =>
api.post<{ status: string, deleted: number }>('/documents/trash/empty'),
getThumbnailUrl: (id: string) => `/api/v1/documents/${id}/thumbnail`,
}

View file

@ -0,0 +1,25 @@
export interface DocumentMeta {
id: string
title: string
filename: string
mime_type: string
size_bytes: number
in_trash: boolean
created_at: string
updated_at: string
deleted_at: string | null
}
export interface DocumentListResponse {
items: DocumentMeta[]
total: number
}
export interface DocumentUpdateRequest {
title?: string
in_trash?: boolean
}
export interface BulkRequest {
ids: string[]
}

View file

@ -0,0 +1,180 @@
import { create } from 'zustand'
import { libraryApi } from './api'
import type { DocumentMeta } from './types'
interface LibraryState {
documents: DocumentMeta[]
total: number
isLoading: boolean
error: string | null
searchQuery: string
// Selection
selectedIds: Set<string>
toggleSelection: (id: string) => void
clearSelection: () => void
selectAll: () => void
// Actions
fetchDocuments: (query?: string) => Promise<void>
fetchTrash: () => Promise<void>
uploadDocument: (file: File) => Promise<DocumentMeta>
deleteDocument: (id: string, permanent?: boolean) => Promise<void>
restoreDocument: (id: string) => Promise<void>
bulkDelete: (permanent?: boolean) => Promise<void>
bulkRestore: () => Promise<void>
emptyTrash: () => Promise<void>
setSearchQuery: (query: string) => void
}
export const useLibrary = create<LibraryState>((set, get) => ({
documents: [],
total: 0,
isLoading: false,
error: null,
searchQuery: '',
selectedIds: new Set(),
toggleSelection: (id) => set((state) => {
const newSelection = new Set(state.selectedIds)
if (newSelection.has(id)) newSelection.delete(id)
else newSelection.add(id)
return { selectedIds: newSelection }
}),
clearSelection: () => set({ selectedIds: new Set() }),
selectAll: () => set((state) => ({
selectedIds: new Set(state.documents.map(d => d.id))
})),
setSearchQuery: (query) => set({ searchQuery: query }),
fetchDocuments: async (query = '') => {
set({ isLoading: true, error: null, searchQuery: query })
try {
const data = await libraryApi.getDocuments(query)
set({ documents: data.items, total: data.total, isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
}
},
fetchTrash: async () => {
set({ isLoading: true, error: null, searchQuery: '' })
try {
const data = await libraryApi.getTrash()
set({ documents: data.items, total: data.total, isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
}
},
uploadDocument: async (file: File) => {
set({ isLoading: true, error: null })
try {
const newDoc = await libraryApi.uploadDocument(file)
set((state) => ({
documents: [newDoc, ...state.documents],
total: state.total + 1,
isLoading: false
}))
return newDoc
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
deleteDocument: async (id: string, permanent = false) => {
set({ isLoading: true, error: null })
try {
await libraryApi.deleteDocument(id, permanent)
set((state) => ({
documents: state.documents.filter(d => d.id !== id),
total: state.total - 1,
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
isLoading: false
}))
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
restoreDocument: async (id: string) => {
set({ isLoading: true, error: null })
try {
await libraryApi.restoreDocument(id)
set((state) => ({
documents: state.documents.filter(d => d.id !== id),
total: state.total - 1,
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
isLoading: false
}))
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
bulkDelete: async (permanent = false) => {
const { selectedIds } = get()
if (selectedIds.size === 0) return
set({ isLoading: true, error: null })
try {
if (permanent) {
// Must delete one by one if permanent
await Promise.all(Array.from(selectedIds).map(id => libraryApi.deleteDocument(id, true)))
} else {
await libraryApi.bulkDelete({ ids: Array.from(selectedIds) })
}
set((state) => ({
documents: state.documents.filter(d => !state.selectedIds.has(d.id)),
total: state.total - state.selectedIds.size,
selectedIds: new Set(),
isLoading: false
}))
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
bulkRestore: async () => {
const { selectedIds } = get()
if (selectedIds.size === 0) return
set({ isLoading: true, error: null })
try {
await libraryApi.bulkRestore({ ids: Array.from(selectedIds) })
set((state) => ({
documents: state.documents.filter(d => !state.selectedIds.has(d.id)),
total: state.total - state.selectedIds.size,
selectedIds: new Set(),
isLoading: false
}))
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
emptyTrash: async () => {
set({ isLoading: true, error: null })
try {
await libraryApi.emptyTrash()
set({
documents: [],
total: 0,
selectedIds: new Set(),
isLoading: false
})
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
}
}))

View file

@ -1,5 +1,8 @@
@import "tailwindcss";
/* Force light mode by requiring a .dark class that we never add */
@custom-variant dark (&:where(.dark, .dark *));
/*
* PaperJet Design Tokens
*
@ -36,27 +39,6 @@
--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;

View file

@ -75,13 +75,21 @@ async function apiFetch<T>(
}
// Parse response
const data = await response.json()
let data: any = {}
try {
const text = await response.text()
if (text) {
data = JSON.parse(text)
}
} catch (err) {
// If it's not JSON, we'll just fall back to the empty object
}
// Handle error responses
if (!response.ok) {
const error: ApiError = data.error ?? {
code: 'unknown',
message: response.statusText,
const error: ApiError = {
code: data.error?.code ?? 'unknown',
message: data.error?.message ?? data.detail ?? response.statusText,
}
throw new ApiRequestError(response.status, error)
}
@ -150,8 +158,17 @@ export const api = {
}
if (!response.ok) {
const data = await response.json()
throw new ApiRequestError(response.status, data.error)
let data: any = {}
try {
const text = await response.text()
if (text) data = JSON.parse(text)
} catch (err) {}
const error: ApiError = {
code: data.error?.code ?? 'unknown',
message: data.error?.message ?? data.detail ?? response.statusText,
}
throw new ApiRequestError(response.status, error)
}
return response.blob()

View file

@ -1,61 +1,36 @@
/**
* Home page recently edited, library, and trash tabs.
* Full implementation in Phase 1.
*/
import { useEffect, useState } from 'react'
import { LibraryHeader } from '../features/library/LibraryHeader'
import { UploadArea } from '../features/library/UploadArea'
import { DocumentGrid } from '../features/library/DocumentGrid'
import { useLibrary } from '../features/library/useLibrary'
export function HomePage() {
const [currentTab, setCurrentTab] = useState<'library' | 'trash'>('library')
const { fetchDocuments, fetchTrash } = useLibrary()
// Initial fetch
useEffect(() => {
if (currentTab === 'library') {
fetchDocuments()
} else {
fetchTrash()
}
}, [currentTab, fetchDocuments, fetchTrash])
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>
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors">
<LibraryHeader currentTab={currentTab} onTabChange={setCurrentTab} />
<main className="max-w-7xl mx-auto px-6 py-8">
{currentTab === 'library' ? (
<UploadArea>
<DocumentGrid isTrash={false} />
</UploadArea>
) : (
<DocumentGrid isTrash={true} />
)}
</main>
</div>
)
}

View file

@ -1,40 +1,13 @@
/**
* Login page handles both first-run password setup and normal login.
* Full implementation in Phase 1.
*/
import { LoginForm } from '@/features/auth/LoginForm'
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 className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] transition-all">
<div className="absolute inset-0 bg-indigo-900/10 dark:bg-black/50 backdrop-blur-[2px]"></div>
<div className="relative z-10 w-full flex justify-center px-4">
<LoginForm />
</div>
</div>
)
}

View file

@ -0,0 +1,12 @@
import { SetupForm } from '@/features/auth/SetupForm'
export function SetupPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] transition-all">
<div className="absolute inset-0 bg-indigo-900/10 dark:bg-black/50 backdrop-blur-[2px]"></div>
<div className="relative z-10 w-full flex justify-center px-4">
<SetupForm />
</div>
</div>
)
}

View file

@ -22,7 +22,13 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View file

@ -1,6 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
@ -8,6 +9,11 @@ export default defineConfig({
react(),
tailwindcss(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
// Dev proxy: route /api requests to the backend
proxy: {