Fix bugs, convert thumbnail to png, add context menu
This commit is contained in:
parent
c545d4b17d
commit
eb8a302222
37 changed files with 1916 additions and 137 deletions
22
frontend/package-lock.json
generated
22
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
32
frontend/src/features/auth/AuthGuard.tsx
Normal file
32
frontend/src/features/auth/AuthGuard.tsx
Normal 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}</>
|
||||
}
|
||||
72
frontend/src/features/auth/LoginForm.tsx
Normal file
72
frontend/src/features/auth/LoginForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
frontend/src/features/auth/SetupForm.tsx
Normal file
107
frontend/src/features/auth/SetupForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
frontend/src/features/auth/api.ts
Normal file
21
frontend/src/features/auth/api.ts
Normal 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'),
|
||||
}
|
||||
7
frontend/src/features/auth/types.ts
Normal file
7
frontend/src/features/auth/types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export interface LoginCredentials {
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface SetupCredentials {
|
||||
password: string
|
||||
}
|
||||
88
frontend/src/features/auth/useAuth.ts
Normal file
88
frontend/src/features/auth/useAuth.ts
Normal 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 })
|
||||
}))
|
||||
98
frontend/src/features/library/ContextMenu.tsx
Normal file
98
frontend/src/features/library/ContextMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
frontend/src/features/library/DocumentGrid.tsx
Normal file
136
frontend/src/features/library/DocumentGrid.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
169
frontend/src/features/library/LibraryHeader.tsx
Normal file
169
frontend/src/features/library/LibraryHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
frontend/src/features/library/UploadArea.tsx
Normal file
86
frontend/src/features/library/UploadArea.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
frontend/src/features/library/api.ts
Normal file
47
frontend/src/features/library/api.ts
Normal 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`,
|
||||
}
|
||||
25
frontend/src/features/library/types.ts
Normal file
25
frontend/src/features/library/types.ts
Normal 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[]
|
||||
}
|
||||
180
frontend/src/features/library/useLibrary.ts
Normal file
180
frontend/src/features/library/useLibrary.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
12
frontend/src/pages/SetupPage.tsx
Normal file
12
frontend/src/pages/SetupPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -22,7 +22,13 @@
|
|||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue