This commit is contained in:
2026-03-21 21:34:53 -04:00
parent 4d133994ab
commit ee1d62858b
22 changed files with 612 additions and 366 deletions

View File

@@ -21,7 +21,7 @@
## Frontend ## Frontend
- **Package manager**: bun (not npm) - **Package manager**: bun (not npm)
- **Icons**: Use `lucide-react` for all icons (not custom SVGs) - **Icons**: Use custom icon components in `src/icons/` (not external icon libraries)
- **Lint**: `cd frontend && bun run lint` (and `lint:fix`) - **Lint**: `cd frontend && bun run lint` (and `lint:fix`)
- **Format**: `cd frontend && bun run format` (and `format:fix`) - **Format**: `cd frontend && bun run format` (and `format:fix`)
- **Generate API client**: `cd frontend && bun run generate:api` - **Generate API client**: `cd frontend && bun run generate:api`

View File

@@ -112,11 +112,23 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
return GetDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil return GetDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
} }
doc, err := s.db.Queries.GetDocument(ctx, request.Id) // Use GetDocumentsWithStats to get document with stats
if err != nil { docs, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
ID: &request.Id,
Deleted: ptrOf(false),
Offset: 0,
Limit: 1,
},
)
if err != nil || len(docs) == 0 {
return GetDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil return GetDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
} }
doc := docs[0]
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{ progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName, UserID: auth.UserName,
DocumentID: request.Id, DocumentID: request.Id,
@@ -132,24 +144,23 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
} }
} }
var percentage *float32
if progress != nil && progress.Percentage != nil {
percentage = ptrOf(float32(*progress.Percentage))
}
apiDoc := Document{ apiDoc := Document{
Id: doc.ID, Id: doc.ID,
Title: *doc.Title, Title: *doc.Title,
Author: *doc.Author, Author: *doc.Author,
Description: doc.Description, Description: doc.Description,
Isbn10: doc.Isbn10, Isbn10: doc.Isbn10,
Isbn13: doc.Isbn13, Isbn13: doc.Isbn13,
Words: doc.Words, Words: doc.Words,
Filepath: doc.Filepath, Filepath: doc.Filepath,
CreatedAt: parseTime(doc.CreatedAt), Percentage: ptrOf(float32(doc.Percentage)),
UpdatedAt: parseTime(doc.UpdatedAt), TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
Deleted: doc.Deleted, Wpm: ptrOf(float32(doc.Wpm)),
Percentage: percentage, SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
LastRead: parseInterfaceTime(doc.LastRead),
CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
Deleted: false, // Default, should be overridden if available
} }
response := DocumentResponse{ response := DocumentResponse{
@@ -197,7 +208,7 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
} }
// Update document with provided editable fields only // Update document with provided editable fields only
updatedDoc, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{ _, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: request.Id, ID: request.Id,
Title: request.Body.Title, Title: request.Body.Title,
Author: request.Body.Author, Author: request.Body.Author,
@@ -216,7 +227,23 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
return EditDocument500JSONResponse{Code: 500, Message: "Failed to update document"}, nil return EditDocument500JSONResponse{Code: 500, Message: "Failed to update document"}, nil
} }
// Get progress for the document // Use GetDocumentsWithStats to get document with stats for the response
docs, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
ID: &request.Id,
Deleted: ptrOf(false),
Offset: 0,
Limit: 1,
},
)
if err != nil || len(docs) == 0 {
return EditDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
doc := docs[0]
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{ progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName, UserID: auth.UserName,
DocumentID: request.Id, DocumentID: request.Id,
@@ -232,24 +259,23 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
} }
} }
var percentage *float32
if progress != nil && progress.Percentage != nil {
percentage = ptrOf(float32(*progress.Percentage))
}
apiDoc := Document{ apiDoc := Document{
Id: updatedDoc.ID, Id: doc.ID,
Title: *updatedDoc.Title, Title: *doc.Title,
Author: *updatedDoc.Author, Author: *doc.Author,
Description: updatedDoc.Description, Description: doc.Description,
Isbn10: updatedDoc.Isbn10, Isbn10: doc.Isbn10,
Isbn13: updatedDoc.Isbn13, Isbn13: doc.Isbn13,
Words: updatedDoc.Words, Words: doc.Words,
Filepath: updatedDoc.Filepath, Filepath: doc.Filepath,
CreatedAt: parseTime(updatedDoc.CreatedAt), Percentage: ptrOf(float32(doc.Percentage)),
UpdatedAt: parseTime(updatedDoc.UpdatedAt), TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
Deleted: updatedDoc.Deleted, Wpm: ptrOf(float32(doc.Wpm)),
Percentage: percentage, SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
LastRead: parseInterfaceTime(doc.LastRead),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Deleted: false,
} }
response := DocumentResponse{ response := DocumentResponse{
@@ -549,7 +575,7 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
} }
// Upsert document with new cover // Upsert document with new cover
updatedDoc, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{ _, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: request.Id, ID: request.Id,
Coverfile: &fileName, Coverfile: &fileName,
}) })
@@ -558,7 +584,23 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to save cover"}, nil return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to save cover"}, nil
} }
// Get progress for the document // Use GetDocumentsWithStats to get document with stats for the response
docs, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
ID: &request.Id,
Deleted: ptrOf(false),
Offset: 0,
Limit: 1,
},
)
if err != nil || len(docs) == 0 {
return UploadDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
doc := docs[0]
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{ progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName, UserID: auth.UserName,
DocumentID: request.Id, DocumentID: request.Id,
@@ -574,24 +616,23 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
} }
} }
var percentage *float32
if progress != nil && progress.Percentage != nil {
percentage = ptrOf(float32(*progress.Percentage))
}
apiDoc := Document{ apiDoc := Document{
Id: updatedDoc.ID, Id: doc.ID,
Title: *updatedDoc.Title, Title: *doc.Title,
Author: *updatedDoc.Author, Author: *doc.Author,
Description: updatedDoc.Description, Description: doc.Description,
Isbn10: updatedDoc.Isbn10, Isbn10: doc.Isbn10,
Isbn13: updatedDoc.Isbn13, Isbn13: doc.Isbn13,
Words: updatedDoc.Words, Words: doc.Words,
Filepath: updatedDoc.Filepath, Filepath: doc.Filepath,
CreatedAt: parseTime(updatedDoc.CreatedAt), Percentage: ptrOf(float32(doc.Percentage)),
UpdatedAt: parseTime(updatedDoc.UpdatedAt), TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
Deleted: updatedDoc.Deleted, Wpm: ptrOf(float32(doc.Wpm)),
Percentage: percentage, SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
LastRead: parseInterfaceTime(doc.LastRead),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Deleted: false,
} }
response := DocumentResponse{ response := DocumentResponse{

View File

@@ -18,7 +18,6 @@
"ajv": "^8.18.0", "ajv": "^8.18.0",
"axios": "^1.13.6", "axios": "^1.13.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"orval": "8.5.3", "orval": "8.5.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -46,10 +46,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
user: userData as { username: string; is_admin: boolean } | null, user: userData as { username: string; is_admin: boolean } | null,
isCheckingAuth: false, isCheckingAuth: false,
}; };
} else if ( } else if (meError || (meData && meData.status === 401)) {
meError ||
(meData && meData.status === 401)
) {
// User is not authenticated or error occurred // User is not authenticated or error occurred
console.log('[AuthContext] User not authenticated:', meError?.message || String(meError)); console.log('[AuthContext] User not authenticated:', meError?.message || String(meError));
return { return {
@@ -77,7 +74,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// The session cookie is automatically set by the browser // The session cookie is automatically set by the browser
setAuthState({ setAuthState({
isAuthenticated: true, isAuthenticated: true,
user: 'username' in response.data ? response.data as { username: string; is_admin: boolean } : null, user:
'username' in response.data
? (response.data as { username: string; is_admin: boolean })
: null,
isCheckingAuth: false, isCheckingAuth: false,
}); });

View File

@@ -0,0 +1,41 @@
import { ReactNode } from 'react';
interface FieldProps {
label: ReactNode;
children: ReactNode;
isEditing?: boolean;
}
export function Field({ label, children, isEditing = false }: FieldProps) {
return (
<div className="relative rounded">
<div className="relative inline-flex gap-2 text-gray-500">{label}</div>
{children}
</div>
);
}
interface FieldLabelProps {
children: ReactNode;
}
export function FieldLabel({ children }: FieldLabelProps) {
return <p>{children}</p>;
}
interface FieldValueProps {
children: ReactNode;
className?: string;
}
export function FieldValue({ children, className = '' }: FieldValueProps) {
return <p className={`text-lg font-medium ${className}`}>{children}</p>;
}
interface FieldActionsProps {
children: ReactNode;
}
export function FieldActions({ children }: FieldActionsProps) {
return <div className="inline-flex gap-2">{children}</div>;
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon } from '../icons'; import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon, GitIcon } from '../icons';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1'; import { useGetInfo } from '../generated/anthoLumeAPIV1';
@@ -184,47 +184,7 @@ export default function HamburgerMenu() {
href="https://gitea.va.reichard.io/evan/AnthoLume" href="https://gitea.va.reichard.io/evan/AnthoLume"
rel="noreferrer" rel="noreferrer"
> >
<svg <GitIcon size={20} />
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-black dark:text-white"
height="20"
viewBox="0 0 219 92"
fill="currentColor"
>
<defs>
<clipPath id="gitea_a">
<path d="M159 .79h25V69h-25Zm0 0" />
</clipPath>
<clipPath id="gitea_b">
<path d="M183 9h35.371v60H183Zm0 0" />
</clipPath>
<clipPath id="gitea_c">
<path d="M0 .79h92V92H0Zm0 0" />
</clipPath>
</defs>
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M130.871 31.836c-4.785 0-8.351 2.352-8.351 8.008 0 4.261 2.347 7.222 8.093 7.222 4.871 0 8.18-2.867 8.18-7.398 0-5.133-2.961-7.832-7.922-7.832Zm-9.57 39.95c-1.133 1.39-2.262 2.87-2.262 4.612 0 3.48 4.434 4.524 10.527 4.524 5.051 0 11.926-.352 11.926-5.043 0-2.793-3.308-2.965-7.488-3.227Zm25.761-39.688c1.563 2.004 3.22 4.789 3.22 8.793 0 9.656-7.571 15.316-18.536 15.316-2.789 0-5.312-.348-6.879-.785l-2.87 4.613 8.526.52c15.059.96 23.934 1.398 23.934 12.968 0 10.008-8.789 15.665-23.934 15.665-15.75 0-21.757-4.004-21.757-10.88 0-3.917 1.742-6 4.789-8.878-2.875-1.211-3.828-3.387-3.828-5.739 0-1.914.953-3.656 2.523-5.312 1.566-1.652 3.305-3.305 5.395-5.219-4.262-2.09-7.485-6.617-7.485-13.058 0-10.008 6.613-16.88 19.93-16.88 3.742 0 6.004.344 8.008.872h16.972v7.394l-8.007.61"
/>
<g clipPath="url(#gitea_a)">
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M170.379 16.281c-4.961 0-7.832-2.87-7.832-7.836 0-4.957 2.871-7.656 7.832-7.656 5.05 0 7.922 2.7 7.922 7.656 0 4.965-2.871 7.836-7.922 7.836Zm-11.227 52.305V61.71l4.438-.606c1.219-.175 1.394-.437 1.394-1.746V33.773c0-.953-.261-1.566-1.132-1.824l-4.7-1.656.957-7.047h18.016V59.36c0 1.399.086 1.57 1.395 1.746l4.437.606v6.875h-24.805"
/>
</g>
<g clipPath="url(#gitea_b)">
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M218.371 65.21c-3.742 1.825-9.223 3.481-14.187 3.481-10.356 0-14.27-4.175-14.27-14.015V31.879c0-.524 0-.871-.7-.871h-6.093v-7.746c7.664-.871 10.707-4.703 11.664-14.188h8.27v12.36c0 .609 0 .87.695.87h12.27v8.704h-12.965v20.797c0 5.136 1.218 7.136 5.918 7.136 2.437 0 4.96-.609 7.047-1.39l2.351 7.66"
/>
</g>
<g clipPath="url(#gitea_c)">
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M89.422 42.371 49.629 2.582a5.868 5.868 0 0 0-8.3 0l-8.263 8.262 10.48 10.484a6.965 6.965 0 0 1 7.173 1.668 6.98 6.98 0 0 1 1.656 7.215l10.102 10.105a6.963 6.963 0 0 1 7.214 1.657 6.976 6.976 0 0 1 0 9.875 6.98 6.98 0 0 1-9.879 0 6.987 6.987 0 0 1-1.519-7.594l-9.422-9.422v24.793a6.979 6.979 0 0 1 1.848 1.32 6.988 6.988 0 0 1 0 9.88c-2.73 2.726-7.153 2.726-9.875 0a6.98 6.98 0 0 1 0-9.88 6.893 6.893 0 0 1 2.285-1.523V34.398a6.893 6.893 0 0 1-2.285-1.523 6.988 6.988 0 0 1-1.508-7.637L29.004 14.902 1.719 42.187a5.868 5.868 0 0 0 0 8.301l39.793 39.793a5.868 5.868 0 0 0 8.3 0l39.61-39.605a5.873 5.873 0 0 0 0-8.305"
/>
</g>
</svg>
<span className="text-xs">{version}</span> <span className="text-xs">{version}</span>
</a> </a>
</div> </div>

View File

@@ -2,8 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom'; import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
import { useGetMe } from '../generated/anthoLumeAPIV1'; import { useGetMe } from '../generated/anthoLumeAPIV1';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { UserIcon } from '../icons'; import { UserIcon, DropdownIcon } from '../icons';
import { ChevronDown } from 'lucide-react';
import HamburgerMenu from './HamburgerMenu'; import HamburgerMenu from './HamburgerMenu';
export default function Layout() { export default function Layout() {
@@ -119,7 +118,7 @@ export default function Layout() {
className="text-gray-800 transition-transform duration-200 dark:text-gray-200" className="text-gray-800 transition-transform duration-200 dark:text-gray-200"
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }} style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
> >
<ChevronDown size={20} /> <DropdownIcon size={20} />
</span> </span>
</button> </button>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Info, AlertTriangle, XCircle, X } from 'lucide-react'; import { InfoIcon, WarningIcon, ErrorIcon, CloseIcon } from '../icons';
export type ToastType = 'info' | 'warning' | 'error'; export type ToastType = 'info' | 'warning' | 'error';
@@ -62,9 +62,9 @@ export function Toast({ id, type, message, duration = 5000, onClose }: ToastProp
} }
const icons = { const icons = {
info: <Info size={20} className={iconStyles[type]} />, info: <InfoIcon size={20} className={iconStyles[type]} />,
warning: <AlertTriangle size={20} className={iconStyles[type]} />, warning: <WarningIcon size={20} className={iconStyles[type]} />,
error: <XCircle size={20} className={iconStyles[type]} />, error: <ErrorIcon size={20} className={iconStyles[type]} />,
}; };
return ( return (
@@ -80,7 +80,7 @@ export function Toast({ id, type, message, duration = 5000, onClose }: ToastProp
className={`ml-2 opacity-70 transition-opacity hover:opacity-100 ${textStyles[type]}`} className={`ml-2 opacity-70 transition-opacity hover:opacity-100 ${textStyles[type]}`}
aria-label="Close" aria-label="Close"
> >
<X size={18} /> <CloseIcon size={18} />
</button> </button>
</div> </div>
); );

View File

@@ -17,3 +17,6 @@ export {
PageLoader, PageLoader,
InlineLoader, InlineLoader,
} from './Skeleton'; } from './Skeleton';
// Field components
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';

View File

@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
/**
* Debounces a value by delaying updates until after a specified delay
* @param value The value to debounce
* @param delay The delay in milliseconds
* @returns The debounced value
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,19 @@
import { BaseIcon } from './BaseIcon';
interface BookIconProps {
size?: number;
className?: string;
disabled?: boolean;
}
export function BookIcon({ size = 24, className = '', disabled = false }: BookIconProps) {
return (
<BaseIcon size={size} className={className} disabled={disabled}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 4.5C4 3.11929 5.11929 2 6.5 2H17.5C18.8807 2 20 3.11929 20 4.5V19.5C20 20.8807 18.8807 22 17.5 22H6.5C5.11929 22 4 20.8807 4 19.5V4.5ZM6.5 3.5C5.94772 3.5 5.5 3.94772 5.5 4.5V19.5C5.5 20.0523 5.94772 20.5 6.5 20.5H17.5C18.0523 20.5 18.5 20.0523 18.5 19.5V4.5C18.5 3.94772 18.0523 3.5 17.5 3.5H6.5ZM12 6C12.4142 6 12.75 6.33579 12.75 6.75V11.5H17.5C17.9142 11.5 18.25 11.8358 18.25 12.25C18.25 12.6642 17.9142 13 17.5 13H12.75V17.75C12.75 18.1642 12.4142 18.5 12 18.5C11.5858 18.5 11.25 18.1642 11.25 17.75V13H6.5C6.08579 13 5.75 12.6642 5.75 12.25C5.75 11.8358 6.08579 11.5 6.5 11.5H11.25V6.75C11.25 6.33579 11.5858 6 12 6Z"
/>
</BaseIcon>
);
}

View File

@@ -0,0 +1,19 @@
import { BaseIcon } from './BaseIcon';
interface CheckIconProps {
size?: number;
className?: string;
disabled?: boolean;
}
export function CheckIcon({ size = 24, className = '', disabled = false }: CheckIconProps) {
return (
<BaseIcon size={size} className={className} disabled={disabled}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20.7071 6.29289C21.0976 6.68342 21.0976 7.31658 20.7071 7.70711L9.70711 18.7071C9.31658 19.0976 8.68342 19.0976 8.29289 18.7071L3.29289 13.7071C2.90237 13.3166 2.90237 12.6834 3.29289 12.2929C3.68342 11.9024 4.31658 11.9024 4.70711 12.2929L9 16.5858L19.2929 6.29289C19.6834 5.90237 20.3166 5.90237 20.7071 6.29289Z"
/>
</BaseIcon>
);
}

View File

@@ -0,0 +1,19 @@
import { BaseIcon } from './BaseIcon';
interface CloseIconProps {
size?: number;
className?: string;
disabled?: boolean;
}
export function CloseIcon({ size = 24, className = '', disabled = false }: CloseIconProps) {
return (
<BaseIcon size={size} className={className} disabled={disabled}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.46967 5.46967C5.76256 5.17678 6.23744 5.17678 6.53033 5.46967L12 10.9393L17.4697 5.46967C17.7626 5.17678 18.2374 5.17678 18.5303 5.46967C18.8232 5.76256 18.8232 6.23744 18.5303 6.53033L13.0607 12L18.5303 17.4697C18.8232 17.7626 18.8232 18.2374 18.5303 18.5303C18.2374 18.8232 17.7626 18.8232 17.4697 18.5303L12 13.0607L6.53033 18.5303C6.23744 18.8232 5.76256 18.8232 5.46967 18.5303C5.17678 18.2374 5.17678 17.7626 5.46967 17.4697L10.9393 12L5.46967 6.53033C5.17678 6.23744 5.17678 5.76256 5.46967 5.46967Z"
/>
</BaseIcon>
);
}

View File

@@ -0,0 +1,19 @@
import { BaseIcon } from './BaseIcon';
interface ErrorIconProps {
size?: number;
className?: string;
disabled?: boolean;
}
export function ErrorIcon({ size = 24, className = '', disabled = false }: ErrorIconProps) {
return (
<BaseIcon size={size} className={className} disabled={disabled}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM8.96967 8.96967C9.26256 8.67678 9.73744 8.67678 10.0303 8.96967L12 10.9393L13.9697 8.96967C14.2626 8.67678 14.7374 8.67678 15.0303 8.96967C15.3232 9.26256 15.3232 9.73744 15.0303 10.0303L13.0607 12L15.0303 13.9697C15.3232 14.2626 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2626 15.3232 13.9697 15.0303L12 13.0607L10.0303 15.0303C9.73744 15.3232 9.26256 15.3232 8.96967 15.0303C8.67678 14.7374 8.67678 14.2626 8.96967 13.9697L10.9393 12L8.96967 10.0303C8.67678 9.73744 8.67678 9.26256 8.96967 8.96967Z"
/>
</BaseIcon>
);
}

View File

@@ -0,0 +1,19 @@
import { BaseIcon } from './BaseIcon';
interface FolderOpenIconProps {
size?: number;
className?: string;
disabled?: boolean;
}
export function FolderOpenIcon({ size = 24, className = '', disabled = false }: FolderOpenIconProps) {
return (
<BaseIcon size={size} className={className} disabled={disabled}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3 6C3 4.34315 4.34315 3 6 3H9.41421C9.81948 3 10.2056 3.17664 10.4718 3.48547L12.4359 5.74999H18C19.6569 5.74999 21 7.09313 21 8.74999V14.5C21 16.1569 19.6569 17.5 18 17.5H16.9282C16.5234 17.5 16.1376 17.3236 15.8714 17.0152L13.9071 14.75H10.0929L8.12855 17.0152C7.86237 17.3236 7.4766 17.5 7.0718 17.5H6C4.34315 17.5 3 16.1569 3 14.5V6ZM18 7.24999H12C11.5947 7.24999 11.2086 7.07334 10.9424 6.76452L8.97821 4.49999H6C5.17157 4.49999 4.5 5.17157 4.5 6V14.5C4.5 15.3284 5.17157 16 6 16H6.5718L8.53615 13.7348C8.80233 13.4264 9.1881 13.25 9.5929 13.25H14.4071C14.8119 13.25 15.1977 13.4264 15.4639 13.7348L17.4282 16H18C18.8284 16 19.5 15.3284 19.5 14.5V8.74999C19.5 7.92156 18.8284 7.24999 18 7.24999Z"
/>
</BaseIcon>
);
}

View File

@@ -0,0 +1,45 @@
export function GitIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="text-black dark:text-white"
height="20"
viewBox="0 0 219 92"
fill="currentColor"
>
<defs>
<clipPath id="a">
<path d="M159 .79h25V69h-25Zm0 0" />
</clipPath>
<clipPath id="b">
<path d="M183 9h35.371v60H183Zm0 0" />
</clipPath>
<clipPath id="c">
<path d="M0 .79h92V92H0Zm0 0" />
</clipPath>
</defs>
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M130.871 31.836c-4.785 0-8.351 2.352-8.351 8.008 0 4.261 2.347 7.222 8.093 7.222 4.871 0 8.18-2.867 8.18-7.398 0-5.133-2.961-7.832-7.922-7.832Zm-9.57 39.95c-1.133 1.39-2.262 2.87-2.262 4.612 0 3.48 4.434 4.524 10.527 4.524 5.051 0 11.926-.352 11.926-5.043 0-2.793-3.308-2.965-7.488-3.227Zm25.761-39.688c1.563 2.004 3.22 4.789 3.22 8.793 0 9.656-7.571 15.316-18.536 15.316-2.789 0-5.312-.348-6.879-.785l-2.87 4.613 8.526.52c15.059.96 23.934 1.398 23.934 12.968 0 10.008-8.789 15.665-23.934 15.665-15.75 0-21.757-4.004-21.757-10.88 0-3.917 1.742-6 4.789-8.878-2.875-1.211-3.828-3.387-3.828-5.739 0-1.914.953-3.656 2.523-5.312 1.566-1.652 3.305-3.305 5.395-5.219-4.262-2.09-7.485-6.617-7.485-13.058 0-10.008 6.613-16.88 19.93-16.88 3.742 0 6.004.344 8.008.872h16.972v7.394l-8.007.61"
/>
<g clip-path="url(#a)">
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M170.379 16.281c-4.961 0-7.832-2.87-7.832-7.836 0-4.957 2.871-7.656 7.832-7.656 5.05 0 7.922 2.7 7.922 7.656 0 4.965-2.871 7.836-7.922 7.836Zm-11.227 52.305V61.71l4.438-.606c1.219-.175 1.394-.437 1.394-1.746V33.773c0-.953-.261-1.566-1.132-1.824l-4.7-1.656.957-7.047h18.016V59.36c0 1.399.086 1.57 1.395 1.746l4.437.606v6.875h-24.805"
/>
</g>
<g clip-path="url(#b)">
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M218.371 65.21c-3.742 1.825-9.223 3.481-14.187 3.481-10.356 0-14.27-4.175-14.27-14.015V31.879c0-.524 0-.871-.7-.871h-6.093v-7.746c7.664-.871 10.707-4.703 11.664-14.188h8.27v12.36c0 .609 0 .87.695.87h12.27v8.704h-12.965v20.797c0 5.136 1.218 7.136 5.918 7.136 2.437 0 4.96-.609 7.047-1.39l2.351 7.66"
/>
</g>
<g clip-path="url(#c)">
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M89.422 42.371 49.629 2.582a5.868 5.868 0 0 0-8.3 0l-8.263 8.262 10.48 10.484a6.965 6.965 0 0 1 7.173 1.668 6.98 6.98 0 0 1 1.656 7.215l10.102 10.105a6.963 6.963 0 0 1 7.214 1.657 6.976 6.976 0 0 1 0 9.875 6.98 6.98 0 0 1-9.879 0 6.987 6.987 0 0 1-1.519-7.594l-9.422-9.422v24.793a6.979 6.979 0 0 1 1.848 1.32 6.988 6.988 0 0 1 0 9.88c-2.73 2.726-7.153 2.726-9.875 0a6.98 6.98 0 0 1 0-9.88 6.893 6.893 0 0 1 2.285-1.523V34.398a6.893 6.893 0 0 1-2.285-1.523 6.988 6.988 0 0 1-1.508-7.637L29.004 14.902 1.719 42.187a5.868 5.868 0 0 0 0 8.301l39.793 39.793a5.868 5.868 0 0 0 8.3 0l39.61-39.605a5.873 5.873 0 0 0 0-8.305"
/>
</g>
</svg>
);
}

View File

@@ -0,0 +1,19 @@
import { BaseIcon } from './BaseIcon';
interface WarningIconProps {
size?: number;
className?: string;
disabled?: boolean;
}
export function WarningIcon({ size = 24, className = '', disabled = false }: WarningIconProps) {
return (
<BaseIcon size={size} className={className} disabled={disabled}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.2859 3.85976C10.9317 2.71341 12.5684 2.71341 13.2141 3.85976L20.7641 16.9978C21.4119 18.1483 20.5822 19.5598 19.2501 19.5598H4.24996C2.9178 19.5598 2.08812 18.1483 2.73591 16.9978L10.2859 3.85976ZM10.9499 13.3098C10.9499 13.724 11.2857 14.0598 11.6999 14.0598C12.1141 14.0598 12.4499 13.724 12.4499 13.3098V8.30979C12.4499 7.89558 12.1141 7.55979 11.6999 7.55979C11.2857 7.55979 10.9499 7.89558 10.9499 8.30979V13.3098ZM10.9499 16.3098C10.9499 16.724 11.2857 17.0598 11.6999 17.0598C12.1141 17.0598 12.4499 16.724 12.4499 16.3098C12.4499 15.8956 12.1141 15.5598 11.6999 15.5598C11.2857 15.5598 10.9499 15.8956 10.9499 16.3098Z"
/>
</BaseIcon>
);
}

View File

@@ -17,3 +17,10 @@ export { DropdownIcon } from './DropdownIcon';
export { ClockIcon } from './ClockIcon'; export { ClockIcon } from './ClockIcon';
export { PasswordIcon } from './PasswordIcon'; export { PasswordIcon } from './PasswordIcon';
export { LoadingIcon } from './LoadingIcon'; export { LoadingIcon } from './LoadingIcon';
export { GitIcon } from './GitIcon';
export { WarningIcon } from './WarningIcon';
export { ErrorIcon } from './ErrorIcon';
export { CloseIcon } from './CloseIcon';
export { CheckIcon } from './CheckIcon';
export { FolderOpenIcon } from './FolderOpenIcon';
export { BookIcon } from './BookIcon';

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1'; import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { FolderOpen } from 'lucide-react'; import { FolderOpenIcon } from '../icons';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
export default function AdminImportPage() { export default function AdminImportPage() {
@@ -73,7 +73,7 @@ export default function AdminImportPage() {
<form className="flex flex-col gap-4" onSubmit={handleImport}> <form className="flex flex-col gap-4" onSubmit={handleImport}>
<div className="flex w-full justify-between gap-4"> <div className="flex w-full justify-between gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<FolderOpen size={20} /> <FolderOpenIcon size={20} />
<p className="break-all text-lg font-medium">{selectedDirectory}</p> <p className="break-all text-lg font-medium">{selectedDirectory}</p>
</div> </div>
<div className="mr-4 flex flex-col justify-around gap-2"> <div className="mr-4 flex flex-col justify-around gap-2">
@@ -151,7 +151,7 @@ export default function AdminImportPage() {
<tr key={item.name}> <tr key={item.name}>
<td className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400"> <td className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
<button onClick={() => item.name && handleSelectDirectory(item.name)}> <button onClick={() => item.name && handleSelectDirectory(item.name)}>
<FolderOpen size={20} /> <FolderOpenIcon size={20} />
</button> </button>
</td> </td>
<td className="border-b border-gray-200 p-3"> <td className="border-b border-gray-200 p-3">

View File

@@ -1,43 +1,30 @@
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useGetDocument, useGetProgress, useEditDocument } from '../generated/anthoLumeAPIV1'; import {
useGetDocument,
useEditDocument,
getGetDocumentQueryKey,
} from '../generated/anthoLumeAPIV1';
import { Document } from '../generated/model/document';
import { Progress } from '../generated/model/progress';
import { useQueryClient } from '@tanstack/react-query';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { DeleteIcon, ActivityIcon, SearchIcon, DownloadIcon, EditIcon, InfoIcon } from '../icons'; import {
import { X, Check } from 'lucide-react'; DeleteIcon,
ActivityIcon,
SearchIcon,
DownloadIcon,
EditIcon,
InfoIcon,
CloseIcon,
CheckIcon,
} from '../icons';
import { useState } from 'react'; import { useState } from 'react';
import { Field, FieldLabel, FieldValue, FieldActions } from '../components';
interface Document {
id: string;
title: string;
author: string;
description?: string;
isbn10?: string;
isbn13?: string;
words?: number;
filepath?: string;
created_at: string;
updated_at: string;
deleted: boolean;
percentage?: number;
total_time_seconds?: number;
wpm?: number;
seconds_per_percent?: number;
last_read?: string;
}
interface Progress {
document_id?: string;
percentage?: number;
created_at?: string;
user_id?: string;
device_name?: string;
title?: string;
author?: string;
}
export default function DocumentPage() { export default function DocumentPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const { data: docData, isLoading: docLoading } = useGetDocument(id || ''); const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
const { data: progressData, isLoading: progressLoading } = useGetProgress(id || '');
const editMutation = useEditDocument(); const editMutation = useEditDocument();
const [showEditCover, setShowEditCover] = useState(false); const [showEditCover, setShowEditCover] = useState(false);
@@ -53,7 +40,7 @@ export default function DocumentPage() {
const [editAuthor, setEditAuthor] = useState(''); const [editAuthor, setEditAuthor] = useState('');
const [editDescription, setEditDescription] = useState(''); const [editDescription, setEditDescription] = useState('');
if (docLoading || progressLoading) { if (docLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>; return <div className="text-gray-500 dark:text-white">Loading...</div>;
} }
@@ -64,14 +51,14 @@ export default function DocumentPage() {
const document = docData.data.document as Document; const document = docData.data.document as Document;
const progress = const progress =
progressData?.status === 200 ? (progressData.data.progress as Progress | undefined) : undefined; docData?.status === 200 ? (docData.data.progress as Progress | undefined) : undefined;
if (!document) { if (!document) {
return <div className="text-gray-500 dark:text-white">Document not found</div>; return <div className="text-gray-500 dark:text-white">Document not found</div>;
} }
// Calculate total time left (mirroring legacy template logic) const percentage =
const percentage = progress?.percentage || document.percentage || 0; document.percentage ?? (progress?.percentage ? progress.percentage * 100 : 0) ?? 0;
const secondsPerPercent = document.seconds_per_percent || 0; const secondsPerPercent = document.seconds_per_percent || 0;
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent); const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
@@ -90,7 +77,11 @@ export default function DocumentPage() {
data: { title: editTitle }, data: { title: editTitle },
}, },
{ {
onSuccess: () => setIsEditingTitle(false), onSuccess: response => {
setIsEditingTitle(false);
// Update cache with the response data (no refetch needed)
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
},
onError: () => setIsEditingTitle(false), onError: () => setIsEditingTitle(false),
} }
); );
@@ -103,7 +94,11 @@ export default function DocumentPage() {
data: { author: editAuthor }, data: { author: editAuthor },
}, },
{ {
onSuccess: () => setIsEditingAuthor(false), onSuccess: response => {
setIsEditingAuthor(false);
// Update cache with the response data (no refetch needed)
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
},
onError: () => setIsEditingAuthor(false), onError: () => setIsEditingAuthor(false),
} }
); );
@@ -116,21 +111,25 @@ export default function DocumentPage() {
data: { description: editDescription }, data: { description: editDescription },
}, },
{ {
onSuccess: () => setIsEditingDescription(false), onSuccess: response => {
setIsEditingDescription(false);
// Update cache with the response data (no refetch needed)
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
},
onError: () => setIsEditingDescription(false), onError: () => setIsEditingDescription(false),
} }
); );
}; };
return ( return (
<div className="relative h-full w-full"> <div className="relative size-full">
<div className="h-full w-full overflow-scroll rounded bg-white p-4 shadow-lg dark:bg-gray-700 dark:text-white"> <div className="size-full overflow-scroll rounded bg-white p-4 shadow-lg dark:bg-gray-700 dark:text-white">
{/* Document Info - Left Column */} {/* Document Info - Left Column */}
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80"> <div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
{/* Cover Image with Edit Label */} {/* Cover Image with Edit Label */}
<label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox"> <label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
<img <img
className="rounded object-fill w-full" className="w-full rounded object-fill"
src={`/api/v1/documents/${document.id}/cover`} src={`/api/v1/documents/${document.id}/cover`}
alt={`${document.title} cover`} alt={`${document.title} cover`}
/> />
@@ -161,7 +160,7 @@ export default function DocumentPage() {
</div> </div>
{/* Icons Container */} {/* Icons Container */}
<div className="relative grow flex justify-between my-auto text-gray-500 dark:text-gray-500"> <div className="relative my-auto flex grow justify-between text-gray-500 dark:text-gray-500">
{/* Edit Cover Dropdown */} {/* Edit Cover Dropdown */}
<div className="relative"> <div className="relative">
<input <input
@@ -172,25 +171,25 @@ export default function DocumentPage() {
onChange={e => setShowEditCover(e.target.checked)} onChange={e => setShowEditCover(e.target.checked)}
/> />
<div <div
className={`absolute z-30 flex flex-col gap-2 top-0 left-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg dark:bg-gray-600 ${ className={`absolute left-0 top-0 z-30 flex flex-col gap-2 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
showEditCover ? 'opacity-100' : 'opacity-0 pointer-events-none' showEditCover ? 'opacity-100' : 'pointer-events-none opacity-0'
}`} }`}
> >
<form className="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"> <form className="flex w-72 flex-col gap-2 text-sm text-black dark:text-white">
<input <input
type="file" type="file"
id="cover_file" id="cover_file"
name="cover_file" name="cover_file"
className="p-2 bg-gray-300" className="bg-gray-300 p-2"
/> />
<button <button
type="submit" type="submit"
className="rounded bg-blue-700 py-1 px-2 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600" className="rounded bg-blue-700 px-2 py-1 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
> >
Upload Cover Upload Cover
</button> </button>
</form> </form>
<form className="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"> <form className="flex w-72 flex-col gap-2 text-sm text-black dark:text-white">
<input <input
type="checkbox" type="checkbox"
checked checked
@@ -200,7 +199,7 @@ export default function DocumentPage() {
/> />
<button <button
type="submit" type="submit"
className="rounded bg-blue-700 py-1 px-2 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600" className="rounded bg-blue-700 px-2 py-1 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
> >
Remove Cover Remove Cover
</button> </button>
@@ -219,14 +218,14 @@ export default function DocumentPage() {
<DeleteIcon size={28} /> <DeleteIcon size={28} />
</button> </button>
<div <div
className={`absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg dark:bg-gray-600 ${ className={`absolute bottom-7 left-5 z-30 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
showDelete ? 'opacity-100' : 'opacity-0 pointer-events-none' showDelete ? 'opacity-100' : 'pointer-events-none opacity-0'
}`} }`}
> >
<form className="text-black dark:text-white text-sm w-24"> <form className="w-24 text-sm text-black dark:text-white">
<button <button
type="submit" type="submit"
className="rounded bg-red-600 py-1 px-2 text-sm font-medium text-white hover:bg-red-700" className="rounded bg-red-600 px-2 py-1 text-sm font-medium text-white hover:bg-red-700"
> >
Delete Delete
</button> </button>
@@ -254,18 +253,18 @@ export default function DocumentPage() {
<SearchIcon size={28} /> <SearchIcon size={28} />
</button> </button>
<div <div
className={`absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg dark:bg-gray-600 ${ className={`absolute bottom-7 left-5 z-30 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
showIdentify ? 'opacity-100' : 'opacity-0 pointer-events-none' showIdentify ? 'opacity-100' : 'pointer-events-none opacity-0'
}`} }`}
> >
<form className="flex flex-col gap-2 text-black dark:text-white text-sm"> <form className="flex flex-col gap-2 text-sm text-black dark:text-white">
<input <input
type="text" type="text"
id="title" id="title"
name="title" name="title"
placeholder="Title" placeholder="Title"
defaultValue={document.title} defaultValue={document.title}
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded" className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
/> />
<input <input
type="text" type="text"
@@ -273,7 +272,7 @@ export default function DocumentPage() {
name="author" name="author"
placeholder="Author" placeholder="Author"
defaultValue={document.author} defaultValue={document.author}
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded" className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
/> />
<input <input
type="text" type="text"
@@ -281,11 +280,11 @@ export default function DocumentPage() {
name="isbn" name="isbn"
placeholder="ISBN 10 / ISBN 13" placeholder="ISBN 10 / ISBN 13"
defaultValue={document.isbn13 || document.isbn10} defaultValue={document.isbn13 || document.isbn10}
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded" className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
/> />
<button <button
type="submit" type="submit"
className="rounded bg-blue-700 py-1 px-2 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600" className="rounded bg-blue-700 px-2 py-1 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
> >
Identify Identify
</button> </button>
@@ -314,221 +313,230 @@ export default function DocumentPage() {
{/* Document Details Grid */} {/* Document Details Grid */}
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2"> <div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
{/* Title - Editable */} {/* Title - Editable */}
<div <Field
className={`relative rounded p-2 ${isEditingTitle ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700' : ''}`} isEditing={isEditingTitle}
label={
<>
<FieldLabel>Title</FieldLabel>
<FieldActions>
{isEditingTitle ? (
<>
<button
type="button"
onClick={() => setIsEditingTitle(false)}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Cancel edit"
>
<CloseIcon size={18} />
</button>
<button
type="button"
onClick={saveTitle}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Confirm edit"
>
<CheckIcon size={18} />
</button>
</>
) : (
<button
type="button"
onClick={() => {
startEditing('title');
setIsEditingTitle(true);
}}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Edit title"
>
<EditIcon size={18} />
</button>
)}
</FieldActions>
</>
}
> >
<div className="relative inline-flex gap-2 text-gray-500">
<p>Title</p>
{isEditingTitle ? (
<div className="inline-flex gap-2">
<button
type="button"
onClick={() => setIsEditingTitle(false)}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Cancel edit"
>
<X size={18} />
</button>
<button
type="button"
onClick={saveTitle}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Confirm edit"
>
<Check size={18} />
</button>
</div>
) : (
<button
type="button"
onClick={() => {
startEditing('title');
setIsEditingTitle(true);
}}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Edit title"
>
<EditIcon size={18} />
</button>
)}
</div>
{isEditingTitle ? ( {isEditingTitle ? (
<div className="relative flex gap-2 mt-1"> <div className="relative mt-1 flex gap-2">
<input <input
type="text" type="text"
value={editTitle} value={editTitle}
onChange={e => setEditTitle(e.target.value)} onChange={e => setEditTitle(e.target.value)}
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded font-medium text-lg flex-grow" className="grow rounded border border-blue-200 bg-blue-50 p-2 text-lg font-medium text-black focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-blue-700 dark:bg-blue-900/20 dark:text-white dark:focus:ring-blue-500"
/> />
</div> </div>
) : ( ) : (
<p className="font-medium text-lg">{document.title}</p> <FieldValue>{document.title}</FieldValue>
)} )}
</div> </Field>
{/* Author - Editable */} {/* Author - Editable */}
<div <Field
className={`relative rounded p-2 ${isEditingAuthor ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700' : ''}`} isEditing={isEditingAuthor}
label={
<>
<FieldLabel>Author</FieldLabel>
<FieldActions>
{isEditingAuthor ? (
<>
<button
type="button"
onClick={() => setIsEditingAuthor(false)}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Cancel edit"
>
<CloseIcon size={18} />
</button>
<button
type="button"
onClick={saveAuthor}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Confirm edit"
>
<CheckIcon size={18} />
</button>
</>
) : (
<button
type="button"
onClick={() => {
startEditing('author');
setIsEditingAuthor(true);
}}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Edit author"
>
<EditIcon size={18} />
</button>
)}
</FieldActions>
</>
}
> >
<div className="relative inline-flex gap-2 text-gray-500">
<p>Author</p>
{isEditingAuthor ? (
<div className="inline-flex gap-2">
<button
type="button"
onClick={() => setIsEditingAuthor(false)}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Cancel edit"
>
<X size={18} />
</button>
<button
type="button"
onClick={saveAuthor}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Confirm edit"
>
<Check size={18} />
</button>
</div>
) : (
<button
type="button"
onClick={() => {
startEditing('author');
setIsEditingAuthor(true);
}}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Edit author"
>
<EditIcon size={18} />
</button>
)}
</div>
{isEditingAuthor ? ( {isEditingAuthor ? (
<div className="relative flex gap-2 mt-1"> <div className="relative mt-1 flex gap-2">
<input <input
type="text" type="text"
value={editAuthor} value={editAuthor}
onChange={e => setEditAuthor(e.target.value)} onChange={e => setEditAuthor(e.target.value)}
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded font-medium text-lg flex-grow" className="grow rounded border border-blue-200 bg-blue-50 p-2 text-lg font-medium text-black focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-blue-700 dark:bg-blue-900/20 dark:text-white dark:focus:ring-blue-500"
/> />
</div> </div>
) : ( ) : (
<p className="font-medium text-lg">{document.author}</p> <FieldValue>{document.author}</FieldValue>
)} )}
</div> </Field>
{/* Time Read with Info Dropdown */} {/* Time Read with Info Dropdown */}
<div className="relative"> <Field
<div className="relative inline-flex gap-2 text-gray-500"> label={
<p>Time Read</p> <>
<button <FieldLabel>Time Read</FieldLabel>
type="button" <button
onClick={() => setShowTimeReadInfo(!showTimeReadInfo)} type="button"
className="my-auto cursor-pointer hover:text-gray-800 dark:hover:text-gray-100" onClick={() => setShowTimeReadInfo(!showTimeReadInfo)}
aria-label="Show time read info" className="my-auto cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
> aria-label="Show time read info"
<InfoIcon size={18} /> >
</button> <InfoIcon size={18} />
<div </button>
className={`absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg dark:bg-gray-600 ${ <div
showTimeReadInfo ? 'opacity-100' : 'opacity-0 pointer-events-none' className={`absolute right-0 top-7 z-30 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
}`} showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
> }`}
<div className="text-xs flex"> >
<p className="text-gray-400 w-32">Seconds / Percent</p> <div className="flex text-xs">
<p className="font-medium dark:text-white"> <p className="w-32 text-gray-400">Seconds / Percent</p>
{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'} <p className="font-medium dark:text-white">
</p> {secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}
</p>
</div>
<div className="flex text-xs">
<p className="w-32 text-gray-400">Words / Minute</p>
<p className="font-medium dark:text-white">
{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}
</p>
</div>
<div className="flex text-xs">
<p className="w-32 text-gray-400">Est. Time Left</p>
<p className="whitespace-nowrap font-medium dark:text-white">
{totalTimeLeftSeconds > 0 ? formatDuration(totalTimeLeftSeconds) : 'N/A'}
</p>
</div>
</div> </div>
<div className="text-xs flex"> </>
<p className="text-gray-400 w-32">Words / Minute</p> }
<p className="font-medium dark:text-white"> >
{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'} <FieldValue>
</p>
</div>
<div className="text-xs flex">
<p className="text-gray-400 w-32">Est. Time Left</p>
<p className="font-medium dark:text-white whitespace-nowrap">
{totalTimeLeftSeconds > 0 ? formatDuration(totalTimeLeftSeconds) : 'N/A'}
</p>
</div>
</div>
</div>
<p className="font-medium text-lg">
{document.total_time_seconds && document.total_time_seconds > 0 {document.total_time_seconds && document.total_time_seconds > 0
? formatDuration(document.total_time_seconds) ? formatDuration(document.total_time_seconds)
: 'N/A'} : 'N/A'}
</p> </FieldValue>
</div> </Field>
{/* Progress */} {/* Progress */}
<div> <Field label={<FieldLabel>Progress</FieldLabel>}>
<p className="text-gray-500">Progress</p> <FieldValue>{`${percentage.toFixed(2)}%`}</FieldValue>
<p className="font-medium text-lg"> </Field>
{percentage ? `${Math.round(percentage)}%` : '0%'}
</p>
</div>
</div> </div>
{/* Description - Editable */} {/* Description - Editable */}
<div <Field
className={`relative rounded p-2 ${isEditingDescription ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700' : ''}`} isEditing={isEditingDescription}
label={
<>
<FieldLabel>Description</FieldLabel>
<FieldActions>
{isEditingDescription ? (
<>
<button
type="button"
onClick={() => setIsEditingDescription(false)}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Cancel edit"
>
<CloseIcon size={18} />
</button>
<button
type="button"
onClick={saveDescription}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Confirm edit"
>
<CheckIcon size={18} />
</button>
</>
) : (
<button
type="button"
onClick={() => {
startEditing('description');
setIsEditingDescription(true);
}}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Edit description"
>
<EditIcon size={18} />
</button>
)}
</FieldActions>
</>
}
> >
<div className="relative inline-flex gap-2 text-gray-500">
<p>Description</p>
{isEditingDescription ? (
<div className="inline-flex gap-2">
<button
type="button"
onClick={() => setIsEditingDescription(false)}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Cancel edit"
>
<X size={18} />
</button>
<button
type="button"
onClick={saveDescription}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Confirm edit"
>
<Check size={18} />
</button>
</div>
) : (
<button
type="button"
onClick={() => {
startEditing('description');
setIsEditingDescription(true);
}}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Edit description"
>
<EditIcon size={18} />
</button>
)}
</div>
{isEditingDescription ? ( {isEditingDescription ? (
<div className="relative flex gap-2 mt-1"> <div className="relative mt-1 flex gap-2">
<textarea <textarea
value={editDescription} value={editDescription}
onChange={e => setEditDescription(e.target.value)} onChange={e => setEditDescription(e.target.value)}
className="h-32 w-full p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded font-medium flex-grow" className="h-32 w-full grow rounded border border-blue-200 bg-blue-50 p-2 font-medium text-black focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-blue-700 dark:bg-blue-900/20 dark:text-white dark:focus:ring-blue-500"
rows={5} rows={5}
/> />
</div> </div>
) : ( ) : (
<div className="relative font-medium text-justify hyphens-auto mt-1"> <FieldValue className="hyphens-auto text-justify">
<p>{document.description || 'N/A'}</p> {document.description || 'N/A'}
</div> </FieldValue>
)} )}
</div> </Field>
{/* Metadata Section */}
{/* TODO: Add metadata component when available */}
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,12 @@
import { useState, FormEvent, useRef } from 'react'; import { useState, FormEvent, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
import type { DocumentsResponse } from '../generated/model/documentsResponse';
import { ActivityIcon, DownloadIcon, SearchIcon, UploadIcon } from '../icons'; import { ActivityIcon, DownloadIcon, SearchIcon, UploadIcon } from '../icons';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { useDebounce } from '../hooks/useDebounce';
interface DocumentCardProps { interface DocumentCardProps {
doc: { doc: {
@@ -87,11 +89,18 @@ export default function DocumentsPage() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { showInfo, showWarning, showError } = useToasts(); const { showInfo, showWarning, showError } = useToasts();
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search }); const debouncedSearch = useDebounce(search, 300);
// Reset to page 1 when search changes
useEffect(() => {
setPage(1);
}, [debouncedSearch]);
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
const createMutation = useCreateDocument(); const createMutation = useCreateDocument();
const docs = data?.data?.documents; const docs = (data?.data as DocumentsResponse | undefined)?.documents;
const previousPage = data?.data?.previous_page; const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
const nextPage = data?.data?.next_page; const nextPage = (data?.data as DocumentsResponse | undefined)?.next_page;
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -128,10 +137,6 @@ export default function DocumentsPage() {
} }
}; };
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Search Form */} {/* Search Form */}
@@ -162,9 +167,11 @@ export default function DocumentsPage() {
{/* Document Grid */} {/* Document Grid */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{docs?.map((doc: any) => ( {isLoading ? (
<DocumentCard key={doc.id} doc={doc} /> <div className="col-span-full text-center text-gray-500 dark:text-white">Loading...</div>
))} ) : (
docs?.map((doc: any) => <DocumentCard key={doc.id} doc={doc} />)
)}
</div> </div>
{/* Pagination */} {/* Pagination */}

View File

@@ -1,8 +1,7 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { useGetSearch } from '../generated/anthoLumeAPIV1'; import { useGetSearch } from '../generated/anthoLumeAPIV1';
import { GetSearchSource } from '../generated/model/getSearchSource'; import { GetSearchSource } from '../generated/model/getSearchSource';
import { SearchIcon, DownloadIcon } from '../icons'; import { SearchIcon, DownloadIcon, BookIcon } from '../icons';
import { Book } from 'lucide-react';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
export default function SearchPage() { export default function SearchPage() {
@@ -39,7 +38,7 @@ export default function SearchPage() {
</div> </div>
<div className="relative flex min-w-[12em]"> <div className="relative flex min-w-[12em]">
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm"> <span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
<Book size={15} /> <BookIcon size={15} />
</span> </span>
<select <select
value={source} value={source}