This commit is contained in:
2026-03-15 21:01:29 -04:00
parent d40f8fc375
commit 4306d86080
73 changed files with 13106 additions and 63 deletions

1
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

111
frontend/README.md Normal file
View File

@@ -0,0 +1,111 @@
# AnthoLume Frontend
A React + TypeScript frontend for AnthoLume, replacing the server-side rendering (SSR) templates.
## Tech Stack
- **React 19** - UI framework
- **TypeScript** - Type safety
- **React Query (TanStack Query)** - Server state management
- **Orval** - API client generation from OpenAPI spec
- **React Router** - Navigation
- **Tailwind CSS** - Styling
- **Vite** - Build tool
- **Axios** - HTTP client with auth interceptors
## Authentication
The frontend includes a complete authentication system:
### Auth Context
- `AuthProvider` - Manages authentication state globally
- `useAuth()` - Hook to access auth state and methods
- Token stored in `localStorage`
- Axios interceptors automatically attach Bearer token to API requests
### Protected Routes
- All main routes are wrapped in `ProtectedRoute`
- Unauthenticated users are redirected to `/login`
- Layout redirects to login if not authenticated
### Login Flow
1. User enters credentials on `/login`
2. POST to `/api/v1/auth/login`
3. Token stored in localStorage
4. Redirect to home page
5. Axios interceptor includes token in subsequent requests
### Logout Flow
1. User clicks "Logout" in dropdown menu
2. POST to `/api/v1/auth/logout`
3. Token cleared from localStorage
4. Redirect to `/login`
### 401 Handling
- Axios response interceptor clears token on 401 errors
- Prevents stale auth state
## Architecture
The frontend mirrors the existing SSR templates structure:
### Pages
- `HomePage` - Landing page with recent documents
- `DocumentsPage` - Document listing with search and pagination
- `DocumentPage` - Single document view with details
- `ProgressPage` - Reading progress table
- `ActivityPage` - User activity log
- `SearchPage` - Search interface
- `SettingsPage` - User settings
- `LoginPage` - Authentication
### Components
- `Layout` - Main layout with navigation sidebar and header
- Generated API hooks from `api/v1/openapi.yaml`
## API Integration
The frontend uses **Orval** to generate TypeScript types and React Query hooks from the OpenAPI spec:
```bash
npm run generate:api
```
This generates:
- Type definitions for all API schemas
- React Query hooks (`useGetDocuments`, `useGetDocument`, etc.)
- Mutation hooks (`useLogin`, `useLogout`)
## Development
```bash
# Install dependencies
npm install
# Generate API types (if OpenAPI spec changes)
npm run generate:api
# Start development server
npm run dev
# Build for production
npm run build
```
## Deployment
The built output is in `dist/` and can be served by the Go backend or deployed separately.
## Migration from SSR
The frontend replicates the functionality of the following SSR templates:
- `templates/pages/home.tmpl``HomePage.tsx`
- `templates/pages/documents.tmpl``DocumentsPage.tsx`
- `templates/pages/document.tmpl``DocumentPage.tsx`
- `templates/pages/progress.tmpl``ProgressPage.tsx`
- `templates/pages/activity.tmpl``ActivityPage.tsx`
- `templates/pages/search.tmpl``SearchPage.tsx`
- `templates/pages/settings.tmpl``SettingsPage.tsx`
- `templates/pages/login.tmpl``LoginPage.tsx`
The styling follows the same Tailwind CSS classes as the original templates for consistency.

File diff suppressed because one or more lines are too long

65
frontend/dist/assets/index-DiNL9yHX.js vendored Normal file

File diff suppressed because one or more lines are too long

32
frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#1F2937"
media="(prefers-color-scheme: dark)"
/>
<title>AnthoLume</title>
<link rel="manifest" href="/manifest.json" />
<script type="module" crossorigin src="/assets/index-DiNL9yHX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8sHRJp6.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

31
frontend/index.html Normal file
View File

@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#1F2937"
media="(prefers-color-scheme: dark)"
/>
<title>AnthoLume</title>
<link rel="manifest" href="/manifest.json" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

21
frontend/orval.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'orval';
export default defineConfig({
antholume: {
output: {
mode: 'split',
baseUrl: '/api/v1',
target: 'src/generated',
schemas: 'src/generated/model',
client: 'react-query',
mock: false,
override: {
useQuery: true,
mutations: true,
},
},
input: {
target: '../api/v1/openapi.yaml',
},
},
});

6945
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "antholume-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"generate:api": "orval"
},
"dependencies": {
"@tanstack/react-query": "^5.62.16",
"axios": "^1.13.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.8",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"orval": "^7.5.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"vite": "^6.0.5"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

12
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { AuthProvider } from './auth/AuthContext';
import { Routes } from './Routes';
function App() {
return (
<AuthProvider>
<Routes />
</AuthProvider>
);
}
export default App;

77
frontend/src/Routes.tsx Normal file
View File

@@ -0,0 +1,77 @@
import { Route, Routes as ReactRoutes } from 'react-router-dom';
import Layout from './components/Layout';
import HomePage from './pages/HomePage';
import DocumentsPage from './pages/DocumentsPage';
import DocumentPage from './pages/DocumentPage';
import ProgressPage from './pages/ProgressPage';
import ActivityPage from './pages/ActivityPage';
import SearchPage from './pages/SearchPage';
import SettingsPage from './pages/SettingsPage';
import LoginPage from './pages/LoginPage';
import { ProtectedRoute } from './auth/ProtectedRoute';
export function Routes() {
return (
<ReactRoutes>
<Route path="/" element={<Layout />}>
<Route
index
element={
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
}
/>
<Route
path="documents"
element={
<ProtectedRoute>
<DocumentsPage />
</ProtectedRoute>
}
/>
<Route
path="documents/:id"
element={
<ProtectedRoute>
<DocumentPage />
</ProtectedRoute>
}
/>
<Route
path="progress"
element={
<ProtectedRoute>
<ProgressPage />
</ProtectedRoute>
}
/>
<Route
path="activity"
element={
<ProtectedRoute>
<ActivityPage />
</ProtectedRoute>
}
/>
<Route
path="search"
element={
<ProtectedRoute>
<SearchPage />
</ProtectedRoute>
}
/>
<Route
path="settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
</Route>
<Route path="/login" element={<LoginPage />} />
</ReactRoutes>
);
}

View File

@@ -0,0 +1,107 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLogin, useLogout, useGetMe } from '../generated/anthoLumeAPIV1';
interface AuthState {
isAuthenticated: boolean;
user: { username: string; is_admin: boolean } | null;
token: string | null;
}
interface AuthContextType extends AuthState {
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = 'antholume_token';
export function AuthProvider({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false,
user: null,
token: null,
});
const loginMutation = useLogin();
const logoutMutation = useLogout();
const { data: meData } = useGetMe(authState.isAuthenticated ? {} : undefined);
const navigate = useNavigate();
// Check for existing token on mount
useEffect(() => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
setAuthState((prev) => ({ ...prev, token, isAuthenticated: true }));
}
}, []);
// Fetch user data when authenticated
useEffect(() => {
if (meData?.data && authState.isAuthenticated) {
setAuthState((prev) => ({
...prev,
user: meData.data,
}));
}
}, [meData, authState.isAuthenticated]);
const login = async (username: string, password: string) => {
try {
loginMutation.mutate({
data: {
username,
password,
},
}, {
onSuccess: () => {
const token = localStorage.getItem(TOKEN_KEY) || 'authenticated';
localStorage.setItem(TOKEN_KEY, token);
setAuthState({
isAuthenticated: true,
user: null,
token,
});
navigate('/');
},
onError: () => {
throw new Error('Login failed');
},
});
} catch (err) {
throw err;
}
};
const logout = () => {
logoutMutation.mutate(undefined, {
onSuccess: () => {
localStorage.removeItem(TOKEN_KEY);
setAuthState({
isAuthenticated: false,
user: null,
token: null,
});
navigate('/login');
},
});
};
return (
<AuthContext.Provider value={{ ...authState, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,18 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
// Redirect to login with the current location saved
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}

View File

@@ -0,0 +1,35 @@
import axios from 'axios';
const TOKEN_KEY = 'antholume_token';
// Request interceptor to add auth token to requests
axios.interceptors.request.use(
(config) => {
const token = localStorage.getItem(TOKEN_KEY);
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle auth errors
axios.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response?.status === 401) {
// Clear token on auth failure
localStorage.removeItem(TOKEN_KEY);
// Optionally redirect to login
// window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default axios;

View File

@@ -0,0 +1,127 @@
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
import { useGetMe } from '../generated/anthoLumeAPIV1';
import { useAuth } from '../auth/AuthContext';
interface NavItem {
path: string;
label: string;
icon: string;
}
const navItems: NavItem[] = [
{ path: '/', label: 'Home', icon: 'home' },
{ path: '/documents', label: 'Documents', icon: 'documents' },
{ path: '/progress', label: 'Progress', icon: 'activity' },
{ path: '/activity', label: 'Activity', icon: 'activity' },
{ path: '/search', label: 'Search', icon: 'search' },
];
export default function Layout() {
const location = useLocation();
const { isAuthenticated, user, logout } = useAuth();
const { data } = useGetMe(isAuthenticated ? {} : undefined);
const userData = data?.data || user;
// Redirect to login if not authenticated
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
const handleLogout = () => {
logout();
};
return (
<div className="bg-gray-100 dark:bg-gray-800 min-h-screen">
{/* Header */}
<div className="flex items-center justify-between w-full h-16">
{/* Mobile Navigation Button */}
<div className="flex flex-col z-40 relative ml-6">
<input
type="checkbox"
className="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0"
id="mobile-nav-toggle"
/>
<span className="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"></span>
<span className="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
<span className="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
<div
id="menu"
className="fixed -ml-6 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg"
>
<div className="h-16 flex justify-end lg:justify-around">
<p className="text-xl font-bold dark:text-white text-right my-auto pr-8 lg:pr-0">
AnthoLume
</p>
</div>
<nav className="flex flex-col">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 ${
location.pathname === item.path
? 'border-purple-500 dark:text-white'
: 'border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
}`}
>
<span className="mx-4 text-sm font-normal">{item.label}</span>
</Link>
))}
</nav>
<a
className="flex flex-col gap-2 justify-center items-center p-6 w-full absolute bottom-0 text-black dark:text-white"
target="_blank"
href="https://gitea.va.reichard.io/evan/AnthoLume"
>
<span className="text-xs">v1.0.0</span>
</a>
</div>
</div>
{/* Header Title */}
<h1 className="text-xl font-bold dark:text-white px-6 lg:ml-44">
<Link to="/documents">Documents</Link>
</h1>
{/* User Dropdown */}
<div className="relative flex items-center justify-end w-full p-4 space-x-4">
<input type="checkbox" id="user-dropdown-button" className="hidden" />
<div
id="user-dropdown"
className="transition duration-200 z-20 absolute right-4 top-16 pt-4"
>
<div className="w-40 origin-top-right bg-white rounded-md shadow-lg dark:shadow-gray-800 dark:bg-gray-700 ring-1 ring-black ring-opacity-5">
<div className="py-1">
<Link
to="/settings"
className="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
>
Settings
</Link>
<button
onClick={handleLogout}
className="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 w-full text-left"
>
Logout
</button>
</div>
</div>
</div>
<label htmlFor="user-dropdown-button">
<div className="flex items-center gap-2 text-gray-500 dark:text-white text-md py-4 cursor-pointer">
<span>{userData?.username || 'User'}</span>
</div>
</label>
</div>
</div>
{/* Main Content */}
<main className="relative overflow-hidden">
<div id="container" className="h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48">
<Outlet />
</div>
</main>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface Activity {
id: string;
user_id: string;
document_id: string;
activity_type: string;
timestamp: string;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { Activity } from './activity';
import type { UserData } from './userData';
export interface ActivityResponse {
activities: Activity[];
user: UserData;
}

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type CreateDocumentBody = {
document_file: Blob;
};

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface DatabaseInfo {
documents_size: number;
activity_size: number;
progress_size: number;
devices_size: number;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface Device {
id?: string;
device_name?: string;
created_at?: string;
last_synced?: string;
}

View File

@@ -0,0 +1,20 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface Document {
id: string;
title: string;
author: string;
created_at: string;
updated_at: string;
deleted: boolean;
words?: number;
filepath?: string;
percentage?: number;
total_time_seconds?: number;
}

View File

@@ -0,0 +1,16 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { Document } from './document';
import type { UserData } from './userData';
import type { Progress } from './progress';
export interface DocumentResponse {
document: Document;
user: UserData;
progress?: Progress;
}

View File

@@ -0,0 +1,22 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { Document } from './document';
import type { UserData } from './userData';
import type { WordCount } from './wordCount';
export interface DocumentsResponse {
documents: Document[];
total: number;
page: number;
limit: number;
next_page?: number;
previous_page?: number;
search?: string;
user: UserData;
word_counts: WordCount[];
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface ErrorResponse {
code: number;
message: string;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type GetActivityParams = {
doc_filter?: boolean;
document_id?: string;
offset?: number;
limit?: number;
};

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type GetDocumentsParams = {
page?: number;
limit?: number;
search?: string;
};

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type GetProgressListParams = {
page?: number;
limit?: number;
document?: string;
};

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { GetSearchSource } from './getSearchSource';
export type GetSearchParams = {
query: string;
source: GetSearchSource;
};

View File

@@ -0,0 +1,16 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type GetSearchSource = typeof GetSearchSource[keyof typeof GetSearchSource];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const GetSearchSource = {
LibGen: 'LibGen',
Annas_Archive: 'Annas Archive',
} as const;

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface GraphDataPoint {
date: string;
minutes_read: number;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { GraphDataPoint } from './graphDataPoint';
import type { UserData } from './userData';
export interface GraphDataResponse {
graph_data: GraphDataPoint[];
user: UserData;
}

View File

@@ -0,0 +1,20 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { DatabaseInfo } from './databaseInfo';
import type { StreaksResponse } from './streaksResponse';
import type { GraphDataResponse } from './graphDataResponse';
import type { UserStatisticsResponse } from './userStatisticsResponse';
import type { UserData } from './userData';
export interface HomeResponse {
database_info: DatabaseInfo;
streaks: StreaksResponse;
graph_data: GraphDataResponse;
user_statistics: UserStatisticsResponse;
user: UserData;
}

View File

@@ -0,0 +1,42 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export * from './activity';
export * from './activityResponse';
export * from './createDocumentBody';
export * from './databaseInfo';
export * from './device';
export * from './document';
export * from './documentResponse';
export * from './documentsResponse';
export * from './errorResponse';
export * from './getActivityParams';
export * from './getDocumentsParams';
export * from './getProgressListParams';
export * from './getSearchParams';
export * from './getSearchSource';
export * from './graphDataPoint';
export * from './graphDataResponse';
export * from './homeResponse';
export * from './leaderboardData';
export * from './leaderboardEntry';
export * from './loginRequest';
export * from './loginResponse';
export * from './postSearchBody';
export * from './progress';
export * from './progressListResponse';
export * from './progressResponse';
export * from './searchItem';
export * from './searchResponse';
export * from './setting';
export * from './settingsResponse';
export * from './streaksResponse';
export * from './userData';
export * from './userStatisticsResponse';
export * from './userStreak';
export * from './wordCount';

View File

@@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { LeaderboardEntry } from './leaderboardEntry';
export interface LeaderboardData {
all: LeaderboardEntry[];
year: LeaderboardEntry[];
month: LeaderboardEntry[];
week: LeaderboardEntry[];
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface LeaderboardEntry {
user_id: string;
value: number;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface LoginRequest {
username: string;
password: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface LoginResponse {
username: string;
is_admin: boolean;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type PostSearchBody = {
source: string;
title: string;
author: string;
id: string;
};

View File

@@ -0,0 +1,17 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface Progress {
title?: string;
author?: string;
device_name?: string;
percentage?: number;
document_id?: string;
user_id?: string;
created_at?: string;
}

View File

@@ -0,0 +1,19 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { Progress } from './progress';
import type { UserData } from './userData';
export interface ProgressListResponse {
progress?: Progress[];
user?: UserData;
page?: number;
limit?: number;
next_page?: number;
previous_page?: number;
total?: number;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { Progress } from './progress';
import type { UserData } from './userData';
export interface ProgressResponse {
progress?: Progress;
user?: UserData;
}

View File

@@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface SearchItem {
id?: string;
title?: string;
author?: string;
language?: string;
series?: string;
file_type?: string;
file_size?: string;
upload_date?: string;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { SearchItem } from './searchItem';
export interface SearchResponse {
results: SearchItem[];
source: string;
query: string;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface Setting {
id: string;
user_id: string;
key: string;
value: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { UserData } from './userData';
import type { Device } from './device';
export interface SettingsResponse {
user: UserData;
timezone?: string;
devices?: Device[];
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { UserStreak } from './userStreak';
import type { UserData } from './userData';
export interface StreaksResponse {
streaks: UserStreak[];
user: UserData;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface UserData {
username: string;
is_admin: boolean;
}

View File

@@ -0,0 +1,16 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { LeaderboardData } from './leaderboardData';
import type { UserData } from './userData';
export interface UserStatisticsResponse {
wpm: LeaderboardData;
duration: LeaderboardData;
words: LeaderboardData;
user: UserData;
}

View File

@@ -0,0 +1,17 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface UserStreak {
window: string;
max_streak: number;
max_streak_start_date: string;
max_streak_end_date: string;
current_streak: number;
current_streak_start_date: string;
current_streak_end_date: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface WordCount {
document_id: string;
count: number;
}

46
frontend/src/index.css Normal file
View File

@@ -0,0 +1,46 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* PWA Styling */
html,
body {
overscroll-behavior-y: none;
margin: 0px;
}
html {
height: calc(100% + env(safe-area-inset-bottom));
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
env(safe-area-inset-left);
}
main {
height: calc(100dvh - 4rem - env(safe-area-inset-top));
}
#container {
padding-bottom: calc(5em + env(safe-area-inset-bottom) * 2);
}
/* No Scrollbar - IE, Edge, Firefox */
* {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* No Scrollbar - WebKit */
*::-webkit-scrollbar {
display: none;
}
/* Button visibility toggle */
.css-button:checked + div {
visibility: visible;
opacity: 1;
}
.css-button + div {
visibility: hidden;
opacity: 0;
}

29
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,29 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './auth/authInterceptor';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
mutations: {
retry: 0,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,43 @@
import { useGetActivity } from '../generated/anthoLumeAPIV1';
export default function ActivityPage() {
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
const activities = data?.data?.activities;
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow">
<table className="min-w-full bg-white dark:bg-gray-700">
<thead>
<tr className="border-b dark:border-gray-600">
<th className="text-left p-3 text-gray-500 dark:text-white">Activity Type</th>
<th className="text-left p-3 text-gray-500 dark:text-white">Document</th>
<th className="text-left p-3 text-gray-500 dark:text-white">Timestamp</th>
</tr>
</thead>
<tbody>
{activities?.map((activity: any) => (
<tr key={activity.id} className="border-b dark:border-gray-600">
<td className="p-3 text-gray-700 dark:text-gray-300">
{activity.activity_type}
</td>
<td className="p-3">
<a href={`/documents/${activity.document_id}`} className="text-blue-600 dark:text-blue-400">
{activity.document_id}
</a>
</td>
<td className="p-3 text-gray-700 dark:text-gray-300">
{new Date(activity.timestamp).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useParams } from 'react-router-dom';
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
export default function DocumentPage() {
const { id } = useParams<{ id: string }>();
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
const { data: progressData, isLoading: progressLoading } = useGetProgress(id || '');
if (docLoading || progressLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
const document = docData?.data?.document;
const progress = progressData?.data;
if (!document) {
return <div className="text-gray-500 dark:text-white">Document not found</div>;
}
return (
<div className="h-full w-full relative">
<div
className="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4"
>
{/* Document Info */}
<div
className="flex flex-col gap-2 float-left w-44 md:w-60 lg:w-80 mr-4 mb-2 relative"
>
<div className="rounded object-fill w-full bg-gray-200 dark:bg-gray-600 h-60">
{/* Cover image placeholder */}
<div className="w-full h-full flex items-center justify-center text-gray-400">
No Cover
</div>
</div>
<a
href={`/reader#id=${document.id}&type=REMOTE`}
className="text-white bg-blue-700 hover:bg-blue-800 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700"
>
Read
</a>
<div className="flex flex-wrap-reverse justify-between gap-2">
<div className="min-w-[50%] md:mr-2">
<div className="flex gap-1 text-sm">
<p className="text-gray-500">Words:</p>
<p className="font-medium">{document.words || 'N/A'}</p>
</div>
</div>
</div>
</div>
{/* Document Details Grid */}
<div className="grid sm:grid-cols-2 justify-between gap-4 pb-4">
<div>
<p className="text-gray-500">Title</p>
<p className="font-medium text-lg">{document.title}</p>
</div>
<div>
<p className="text-gray-500">Author</p>
<p className="font-medium text-lg">{document.author}</p>
</div>
<div>
<p className="text-gray-500">Time Read</p>
<p className="font-medium text-lg">
{progress?.progress?.percentage ? `${Math.round(progress.progress.percentage)}%` : '0%'}
</p>
</div>
<div>
<p className="text-gray-500">Progress</p>
<p className="font-medium text-lg">
{progress?.progress?.percentage ? `${Math.round(progress.progress.percentage)}%` : '0%'}
</p>
</div>
</div>
{/* Description */}
<div className="relative">
<div className="text-gray-500 inline-flex gap-2 relative">
<p>Description</p>
</div>
<div className="relative font-medium text-justify hyphens-auto">
<p>N/A</p>
</div>
</div>
{/* Stats */}
<div className="mt-4 grid sm:grid-cols-3 gap-4">
<div>
<p className="text-gray-500">Words</p>
<p className="font-medium">{document.words || 'N/A'}</p>
</div>
<div>
<p className="text-gray-500">Created</p>
<p className="font-medium">
{new Date(document.created_at).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-gray-500">Updated</p>
<p className="font-medium">
{new Date(document.updated_at).toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,312 @@
import { useState, FormEvent, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
interface DocumentCardProps {
doc: {
id: string;
title: string;
author: string;
created_at: string;
deleted: boolean;
words?: number;
filepath?: string;
percentage?: number;
total_time_seconds?: number;
};
}
// Activity icon SVG
function ActivityIcon() {
return (
<svg className="w-20 h-20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
);
}
// Download icon SVG
function DownloadIcon({ disabled }: { disabled?: boolean }) {
if (disabled) {
return (
<svg className="w-20 h-20 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="21 15 16 10 8 10" />
<line x1="12" y1="3" x2="12" y2="21" />
<line x1="21" y1="15" x2="21" y2="15" opacity="0" />
</svg>
);
}
return (
<svg className="w-20 h-20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="21 15 16 10 8 10" />
<line x1="12" y1="3" x2="12" y2="21" />
</svg>
);
}
function DocumentCard({ doc }: DocumentCardProps) {
const percentage = doc.percentage || 0;
const totalTimeSeconds = doc.total_time_seconds || 0;
// Convert seconds to nice format (e.g., "2h 30m")
const niceSeconds = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
return (
<div className="w-full relative">
<div
className="flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded"
>
<div className="min-w-fit my-auto h-48 relative">
<Link to={`/documents/${doc.id}`}>
<img
className="rounded object-cover h-full"
src={`/api/v1/documents/${doc.id}/cover`}
alt={doc.title}
/>
</Link>
</div>
<div className="flex flex-col justify-around dark:text-white w-full text-sm">
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-gray-400">Title</p>
<p className="font-medium">{doc.title || "Unknown"}</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-gray-400">Author</p>
<p className="font-medium">{doc.author || "Unknown"}</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-gray-400">Progress</p>
<p className="font-medium">{percentage}%</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-gray-400">Time Read</p>
<p className="font-medium">{niceSeconds(totalTimeSeconds)}</p>
</div>
</div>
</div>
<div
className="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400"
>
<Link to={`/activity?document=${doc.id}`}>
<ActivityIcon />
</Link>
{doc.filepath ? (
<Link to={`/documents/${doc.id}/file`}>
<DownloadIcon />
</Link>
) : (
<DownloadIcon disabled />
)}
</div>
</div>
</div>
);
}
// Search icon SVG
function SearchIcon() {
return (
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-6-6" />
</svg>
);
}
// Upload icon SVG
function UploadIcon() {
return (
<svg className="w-34 h-34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
);
}
export default function DocumentsPage() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [limit] = useState(9);
const [uploadMode, setUploadMode] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search });
const createMutation = useCreateDocument();
const docs = data?.data?.documents;
const previousPage = data?.data?.previous_page;
const nextPage = data?.data?.next_page;
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
refetch();
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.epub')) {
alert('Please upload an EPUB file');
return;
}
try {
await createMutation.mutateAsync({
data: {
document_file: file,
},
});
alert('Document uploaded successfully!');
setUploadMode(false);
refetch();
} catch (error) {
console.error('Upload failed:', error);
alert('Failed to upload document');
}
};
const handleCancelUpload = () => {
setUploadMode(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
return (
<div className="flex flex-col gap-4">
{/* Search Form */}
<div
className="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<form className="flex gap-4 flex-col lg:flex-row" onSubmit={handleSubmit}>
<div className="flex flex-col w-full grow">
<div className="flex relative">
<span
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<SearchIcon />
</span>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="Search Author / Title"
name="search"
/>
</div>
</div>
<div className="lg:w-60">
<button
type="submit"
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
>
Search
</button>
</div>
</form>
</div>
{/* Document Grid */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{docs?.map((doc: any) => (
<DocumentCard key={doc.id} doc={doc} />
))}
</div>
{/* Pagination */}
<div className="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
{previousPage && previousPage > 0 && (
<button
onClick={() => setPage(page - 1)}
className="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"
>
</button>
)}
{nextPage && nextPage > 0 && (
<button
onClick={() => setPage(page + 1)}
className="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"
>
</button>
)}
</div>
{/* Upload Button */}
<div
className="fixed bottom-6 right-6 rounded-full flex items-center justify-center"
>
<input
type="checkbox"
id="upload-file-button"
className="hidden"
checked={uploadMode}
onChange={() => setUploadMode(!uploadMode)}
/>
<div
className={`absolute right-0 z-10 bottom-0 rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2 ${uploadMode ? 'display-block' : 'display-none'}`}
>
<form
method="POST"
encType="multipart/form-data"
className="flex flex-col gap-2"
>
<input
type="file"
accept=".epub"
id="document_file"
name="document_file"
ref={fileInputRef}
onChange={handleFileChange}
/>
<button
className="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
type="submit"
onClick={(e) => {
e.preventDefault();
handleFileChange({ target: { files: fileInputRef.current?.files } } as any);
}}
>
Upload File
</button>
</form>
<label htmlFor="upload-file-button">
<div
className="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={handleCancelUpload}
>
Cancel Upload
</div>
</label>
</div>
<label
className="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
htmlFor="upload-file-button"
>
<UploadIcon />
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
import { Link } from 'react-router-dom';
import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1';
import type { GraphDataPoint, LeaderboardData } from '../generated/model';
interface InfoCardProps {
title: string;
size: string | number;
link?: string;
}
function InfoCard({ title, size, link }: InfoCardProps) {
if (link) {
return (
<Link to={link} className="w-full">
<div className="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
<div className="flex flex-col justify-around dark:text-white w-full text-sm">
<p className="text-2xl font-bold text-black dark:text-white">{size}</p>
<p className="text-sm text-gray-400">{title}</p>
</div>
</div>
</Link>
);
}
return (
<div className="w-full">
<div className="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
<div className="flex flex-col justify-around dark:text-white w-full text-sm">
<p className="text-2xl font-bold text-black dark:text-white">{size}</p>
<p className="text-sm text-gray-400">{title}</p>
</div>
</div>
</div>
);
}
interface StreakCardProps {
window: 'DAY' | 'WEEK';
currentStreak: number;
currentStreakStartDate: string;
currentStreakEndDate: string;
maxStreak: number;
maxStreakStartDate: string;
maxStreakEndDate: string;
}
function StreakCard({ window, currentStreak, currentStreakStartDate, currentStreakEndDate, maxStreak, maxStreakStartDate, maxStreakEndDate }: StreakCardProps) {
return (
<div className="w-full">
<div className="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
<p className="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'}
</p>
<div className="flex items-end my-6 space-x-2">
<p className="text-5xl font-bold text-black dark:text-white">{currentStreak}</p>
</div>
<div className="dark:text-white">
<div className="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200">
<div>
<p>{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
<div className="flex items-end text-sm text-gray-400">
{currentStreakStartDate} {currentStreakEndDate}
</div>
</div>
<div className="flex items-end font-bold">{currentStreak}</div>
</div>
<div className="flex items-center justify-between pb-2 mb-2 text-sm">
<div>
<p>{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
<div className="flex items-end text-sm text-gray-400">
{maxStreakStartDate} {maxStreakEndDate}
</div>
</div>
<div className="flex items-end font-bold">{maxStreak}</div>
</div>
</div>
</div>
</div>
);
}
interface LeaderboardCardProps {
name: string;
data: LeaderboardData;
}
function LeaderboardCard({ name, data }: LeaderboardCardProps) {
return (
<div className="w-full">
<div className="flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
<div>
<div className="flex justify-between">
<p className="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
{name} Leaderboard
</p>
<div className="flex gap-2 text-xs text-gray-400 items-center">
<span className="cursor-pointer hover:text-black dark:hover:text-white">all</span>
<span className="cursor-pointer hover:text-black dark:hover:text-white">year</span>
<span className="cursor-pointer hover:text-black dark:hover:text-white">month</span>
<span className="cursor-pointer hover:text-black dark:hover:text-white">week</span>
</div>
</div>
</div>
{/* All time data */}
<div className="flex items-end my-6 space-x-2">
{data.all.length === 0 ? (
<p className="text-5xl font-bold text-black dark:text-white">N/A</p>
) : (
<p className="text-5xl font-bold text-black dark:text-white">{data.all[0]?.user_id || 'N/A'}</p>
)}
</div>
<div className="dark:text-white">
{data.all.slice(0, 3).map((item: any, index: number) => (
<div
key={index}
className={`flex items-center justify-between pt-2 pb-2 text-sm ${index > 0 ? 'border-t border-gray-200' : ''}`}
>
<div>
<p>{item.user_id}</p>
</div>
<div className="flex items-end font-bold">{item.value}</div>
</div>
))}
</div>
</div>
</div>
);
}
function GraphVisualization({ data }: { data: GraphDataPoint[] }) {
if (!data || data.length === 0) {
return (
<div className="relative h-24 flex items-center justify-center bg-gray-100 dark:bg-gray-600">
<p className="text-gray-400 dark:text-gray-300">No data available</p>
</div>
);
}
// Simple bar visualization (could be enhanced with SVG bezier curve like SSR)
const maxMinutes = Math.max(...data.map(d => d.minutes_read), 1);
return (
<div className="relative h-24 flex items-end justify-between p-2 bg-gray-100 dark:bg-gray-600">
{data.map((point, i) => (
<div
key={i}
className="flex-1 mx-0.5 bg-blue-500 hover:bg-blue-600 transition-colors relative group"
style={{ height: `${(point.minutes_read / maxMinutes) * 100}%` }}
>
<div className="absolute bottom-full mb-1 left-0 w-full text-xs text-center text-gray-600 dark:text-gray-300 opacity-0 group-hover:opacity-100 pointer-events-none">
{point.minutes_read} min
</div>
</div>
))}
</div>
);
}
export default function HomePage() {
const { data: homeData, isLoading: homeLoading } = useGetHome();
const { data: docsData, isLoading: docsLoading } = useGetDocuments({ page: 1, limit: 9 });
const docs = docsData?.data?.documents;
const dbInfo = homeData?.data?.database_info;
const streaks = homeData?.data?.streaks?.streaks;
const graphData = homeData?.data?.graph_data?.graph_data;
const userStats = homeData?.data?.user_statistics;
if (homeLoading || docsLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
return (
<div className="flex flex-col gap-4">
{/* Daily Read Totals Graph */}
<div className="w-full">
<div className="relative w-full bg-white shadow-lg dark:bg-gray-700 rounded">
<p className="absolute top-3 left-5 text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
Daily Read Totals
</p>
<GraphVisualization data={graphData || []} />
</div>
</div>
{/* Info Cards */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<InfoCard
title="Documents"
size={dbInfo?.documents_size || 0}
link="./documents"
/>
<InfoCard
title="Activity Records"
size={dbInfo?.activity_size || 0}
link="./activity"
/>
<InfoCard
title="Progress Records"
size={dbInfo?.progress_size || 0}
link="./progress"
/>
<InfoCard
title="Devices"
size={dbInfo?.devices_size || 0}
/>
</div>
{/* Streak Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{streaks?.map((streak: any, index) => (
<StreakCard
key={index}
window={streak.window as 'DAY' | 'WEEK'}
currentStreak={streak.current_streak}
currentStreakStartDate={streak.current_streak_start_date}
currentStreakEndDate={streak.current_streak_end_date}
maxStreak={streak.max_streak}
maxStreakStartDate={streak.max_streak_start_date}
maxStreakEndDate={streak.max_streak_end_date}
/>
))}
</div>
{/* Leaderboard Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<LeaderboardCard
name="WPM"
data={userStats?.wpm || { all: [], year: [], month: [], week: []}}
/>
<LeaderboardCard
name="Duration"
data={userStats?.duration || { all: [], year: [], month: [], week: []}}
/>
<LeaderboardCard
name="Words"
data={userStats?.words || { all: [], year: [], month: [], week: []}}
/>
</div>
{/* Recent Documents */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{docs?.slice(0, 6).map((doc: any) => (
<div
key={doc.id}
className="flex flex-col gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<h3 className="font-medium text-lg">{doc.title}</h3>
<p className="text-sm">{doc.author}</p>
<Link
to={`/documents/${doc.id}`}
className="text-white bg-blue-700 hover:bg-blue-800 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700"
>
View Document
</Link>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useState, FormEvent } from 'react';
import { useAuth } from '../auth/AuthContext';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await login(username, password);
} catch (err) {
setError('Invalid credentials');
} finally {
setIsLoading(false);
}
};
return (
<div className="bg-gray-100 dark:bg-gray-800 dark:text-white min-h-screen">
<div className="flex flex-wrap w-full">
<div className="flex flex-col w-full md:w-1/2">
<div
className="flex flex-col justify-center px-8 pt-8 my-auto md:justify-start md:pt-0 md:px-24 lg:px-32"
>
<p className="text-3xl text-center">Welcome.</p>
<form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
<div className="flex flex-col pt-4">
<div className="flex relative">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="Username"
required
disabled={isLoading}
/>
</div>
</div>
<div className="flex flex-col pt-4 mb-12">
<div className="flex relative">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="Password"
required
disabled={isLoading}
/>
<span className="absolute -bottom-5 text-red-400 text-xs">{error}</span>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full px-4 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2 disabled:opacity-50"
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
<div className="pt-12 pb-12 text-center">
<p className="mt-4">
<a href="/local" className="font-semibold underline">
Offline / Local Mode
</a>
</p>
</div>
</div>
</div>
<div className="hidden image-fader w-1/2 shadow-2xl h-screen relative md:block">
<div className="w-full h-screen object-cover ease-in-out top-0 left-0 bg-gray-300 flex items-center justify-center">
<span className="text-gray-500">AnthoLume</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { Link } from 'react-router-dom';
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
export default function ProgressPage() {
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
const progress = data?.data?.progress;
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow">
<table className="min-w-full bg-white dark:bg-gray-700">
<thead>
<tr className="border-b dark:border-gray-600">
<th className="text-left p-3 text-gray-500 dark:text-white">Document</th>
<th className="text-left p-3 text-gray-500 dark:text-white">Device Name</th>
<th className="text-left p-3 text-gray-500 dark:text-white">Percentage</th>
<th className="text-left p-3 text-gray-500 dark:text-white">Created At</th>
</tr>
</thead>
<tbody>
{progress?.map((row: any) => (
<tr key={row.document_id} className="border-b dark:border-gray-600">
<td className="p-3">
<Link
to={`/documents/${row.document_id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link>
</td>
<td className="p-3 text-gray-700 dark:text-gray-300">
{row.device_name || 'Unknown'}
</td>
<td className="p-3 text-gray-700 dark:text-gray-300">
{row.percentage ? Math.round(row.percentage) : 0}%
</td>
<td className="p-3 text-gray-700 dark:text-gray-300">
{row.created_at ? new Date(row.created_at).toLocaleDateString() : 'N/A'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { useState, FormEvent } from 'react';
import { useGetSearch } from '../generated/anthoLumeAPIV1';
import { GetSearchSource } from '../generated/model/getSearchSource';
// Search icon SVG
function SearchIcon() {
return (
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-6-6" />
</svg>
);
}
// Documents icon SVG
function DocumentsIcon() {
return (
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
);
}
// Download icon SVG
function DownloadIcon() {
return (
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="21 15 16 10 8 10" />
<line x1="12" y1="3" x2="12" y2="21" />
</svg>
);
}
export default function SearchPage() {
const [query, setQuery] = useState('');
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
const { data, isLoading } = useGetSearch({ query, source });
const results = data?.data?.results;
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
// Trigger refetch by updating query
};
return (
<div className="w-full flex flex-col md:flex-row gap-4">
<div className="flex flex-col gap-4 grow">
{/* Search Form */}
<div
className="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<form className="flex gap-4 flex-col lg:flex-row" onSubmit={handleSubmit}>
<div className="flex flex-col w-full grow">
<div className="flex relative">
<span
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<SearchIcon />
</span>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="Query"
/>
</div>
</div>
<div className="flex relative min-w-[12em]">
<span
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<DocumentsIcon />
</span>
<select
value={source}
onChange={(e) => setSource(e.target.value as GetSearchSource)}
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
>
<option value="LibGen">Library Genesis</option>
<option value="Annas Archive">Annas Archive</option>
</select>
</div>
<div className="lg:w-60">
<button
type="submit"
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
>
Search
</button>
</div>
</form>
</div>
{/* Search Results Table */}
<div className="inline-block min-w-full overflow-hidden rounded shadow">
<table
className="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm"
>
<thead className="text-gray-800 dark:text-gray-400">
<tr>
<th
className="w-12 p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
></th>
<th
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Document
</th>
<th
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Series
</th>
<th
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Type
</th>
<th
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Size
</th>
<th
className="p-3 hidden md:block font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Date
</th>
</tr>
</thead>
<tbody className="text-black dark:text-white">
{isLoading && (
<tr>
<td className="text-center p-3" colSpan={6}>Loading...</td>
</tr>
)}
{!isLoading && !results && (
<tr>
<td className="text-center p-3" colSpan={6}>No Results</td>
</tr>
)}
{!isLoading && results && results.map((item: any) => (
<tr key={item.id}>
<td
className="p-3 border-b border-gray-200 text-gray-500 dark:text-gray-500"
>
<button
className="hover:text-purple-600"
title="Download"
>
<DownloadIcon />
</button>
</td>
<td className="p-3 border-b border-gray-200">
{item.author || 'N/A'} - {item.title || 'N/A'}
</td>
<td className="p-3 border-b border-gray-200">
<p>{item.series || 'N/A'}</p>
</td>
<td className="p-3 border-b border-gray-200">
<p>{item.file_type || 'N/A'}</p>
</td>
<td className="p-3 border-b border-gray-200">
<p>{item.file_size || 'N/A'}</p>
</td>
<td className="hidden md:table-cell p-3 border-b border-gray-200">
<p>{item.upload_date || 'N/A'}</p>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { useState, FormEvent } from 'react';
import { useGetSettings } from '../generated/anthoLumeAPIV1';
// User icon SVG
function UserIcon() {
return (
<svg className="w-60 h-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="8" r="4" />
<path d="M12 12c-4 0-8 3-8 8h16c0-5-4-8-8-8" />
</svg>
);
}
// Password icon SVG
function PasswordIcon() {
return (
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
// Clock icon SVG
function ClockIcon() {
return (
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
}
export default function SettingsPage() {
const { data, isLoading } = useGetSettings();
const settingsData = data?.data;
const [password, setPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [timezone, setTimezone] = useState(settingsData?.timezone || '');
const handlePasswordSubmit = (e: FormEvent) => {
e.preventDefault();
// TODO: Call API to change password
};
const handleTimezoneSubmit = (e: FormEvent) => {
e.preventDefault();
// TODO: Call API to change timezone
};
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
return (
<div className="w-full flex flex-col md:flex-row gap-4">
{/* User Profile Card */}
<div>
<div
className="flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<UserIcon />
<p className="text-lg">{settingsData?.user?.username}</p>
</div>
</div>
<div className="flex flex-col gap-4 grow">
{/* Change Password Form */}
<div
className="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p className="text-lg font-semibold mb-2">Change Password</p>
<form
className="flex gap-4 flex-col lg:flex-row"
onSubmit={handlePasswordSubmit}
>
<div className="flex flex-col grow">
<div className="flex relative">
<span
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<PasswordIcon />
</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="Password"
/>
</div>
</div>
<div className="flex flex-col grow">
<div className="flex relative">
<span
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<PasswordIcon />
</span>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="New Password"
/>
</div>
</div>
<div className="lg:w-60">
<button
type="submit"
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
>
Submit
</button>
</div>
</form>
</div>
{/* Change Timezone Form */}
<div
className="flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p className="text-lg font-semibold mb-2">Change Timezone</p>
<form
className="flex gap-4 flex-col lg:flex-row"
onSubmit={handleTimezoneSubmit}
>
<div className="flex relative grow">
<span
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<ClockIcon />
</span>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Chicago">America/Chicago</option>
<option value="America/Denver">America/Denver</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="Europe/London">Europe/London</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="Asia/Shanghai">Asia/Shanghai</option>
<option value="Australia/Sydney">Australia/Sydney</option>
</select>
</div>
<div className="lg:w-60">
<button
type="submit"
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
>
Submit
</button>
</div>
</form>
</div>
{/* Devices Table */}
<div
className="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p className="text-lg font-semibold">Devices</p>
<table className="min-w-full bg-white dark:bg-gray-700 text-sm">
<thead className="text-gray-800 dark:text-gray-400">
<tr>
<th
className="p-3 pl-0 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Name
</th>
<th
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Last Sync
</th>
<th
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Created
</th>
</tr>
</thead>
<tbody className="text-black dark:text-white">
{!settingsData?.devices || settingsData.devices.length === 0 ? (
<tr>
<td className="text-center p-3" colSpan={3}>No Results</td>
</tr>
) : (
settingsData.devices.map((device: any) => (
<tr key={device.id}>
<td className="p-3 pl-0">
<p>{device.device_name || 'Unknown'}</p>
</td>
<td className="p-3">
<p>{device.last_synced ? new Date(device.last_synced).toLocaleString() : 'N/A'}</p>
</td>
<td className="p-3">
<p>{device.created_at ? new Date(device.created_at).toLocaleString() : 'N/A'}</p>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
};

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Module resolution options */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* JSX support */
"jsx": "react-jsx",
/* Strict type checking */
"strict": true,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["src"]
}

21
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8585',
changeOrigin: true,
},
'/assets': {
target: 'http://localhost:8585',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
},
});