wip 17
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
|
||||
## Frontend
|
||||
- **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`)
|
||||
- **Format**: `cd frontend && bun run format` (and `format:fix`)
|
||||
- **Generate API client**: `cd frontend && bun run generate:api`
|
||||
|
||||
@@ -112,11 +112,23 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
|
||||
return GetDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||
}
|
||||
|
||||
doc, err := s.db.Queries.GetDocument(ctx, request.Id)
|
||||
if err != nil {
|
||||
// Use GetDocumentsWithStats to get document with stats
|
||||
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
|
||||
}
|
||||
|
||||
doc := docs[0]
|
||||
|
||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
||||
UserID: auth.UserName,
|
||||
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{
|
||||
Id: doc.ID,
|
||||
Title: *doc.Title,
|
||||
Author: *doc.Author,
|
||||
Description: doc.Description,
|
||||
Isbn10: doc.Isbn10,
|
||||
Isbn13: doc.Isbn13,
|
||||
Words: doc.Words,
|
||||
Filepath: doc.Filepath,
|
||||
CreatedAt: parseTime(doc.CreatedAt),
|
||||
UpdatedAt: parseTime(doc.UpdatedAt),
|
||||
Deleted: doc.Deleted,
|
||||
Percentage: percentage,
|
||||
Id: doc.ID,
|
||||
Title: *doc.Title,
|
||||
Author: *doc.Author,
|
||||
Description: doc.Description,
|
||||
Isbn10: doc.Isbn10,
|
||||
Isbn13: doc.Isbn13,
|
||||
Words: doc.Words,
|
||||
Filepath: doc.Filepath,
|
||||
Percentage: ptrOf(float32(doc.Percentage)),
|
||||
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
|
||||
Wpm: ptrOf(float32(doc.Wpm)),
|
||||
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{
|
||||
@@ -197,7 +208,7 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
|
||||
}
|
||||
|
||||
// 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,
|
||||
Title: request.Body.Title,
|
||||
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
|
||||
}
|
||||
|
||||
// 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{
|
||||
UserID: auth.UserName,
|
||||
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{
|
||||
Id: updatedDoc.ID,
|
||||
Title: *updatedDoc.Title,
|
||||
Author: *updatedDoc.Author,
|
||||
Description: updatedDoc.Description,
|
||||
Isbn10: updatedDoc.Isbn10,
|
||||
Isbn13: updatedDoc.Isbn13,
|
||||
Words: updatedDoc.Words,
|
||||
Filepath: updatedDoc.Filepath,
|
||||
CreatedAt: parseTime(updatedDoc.CreatedAt),
|
||||
UpdatedAt: parseTime(updatedDoc.UpdatedAt),
|
||||
Deleted: updatedDoc.Deleted,
|
||||
Percentage: percentage,
|
||||
Id: doc.ID,
|
||||
Title: *doc.Title,
|
||||
Author: *doc.Author,
|
||||
Description: doc.Description,
|
||||
Isbn10: doc.Isbn10,
|
||||
Isbn13: doc.Isbn13,
|
||||
Words: doc.Words,
|
||||
Filepath: doc.Filepath,
|
||||
Percentage: ptrOf(float32(doc.Percentage)),
|
||||
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
|
||||
Wpm: ptrOf(float32(doc.Wpm)),
|
||||
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
|
||||
LastRead: parseInterfaceTime(doc.LastRead),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Deleted: false,
|
||||
}
|
||||
|
||||
response := DocumentResponse{
|
||||
@@ -549,7 +575,7 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
|
||||
}
|
||||
|
||||
// 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,
|
||||
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
|
||||
}
|
||||
|
||||
// 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{
|
||||
UserID: auth.UserName,
|
||||
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{
|
||||
Id: updatedDoc.ID,
|
||||
Title: *updatedDoc.Title,
|
||||
Author: *updatedDoc.Author,
|
||||
Description: updatedDoc.Description,
|
||||
Isbn10: updatedDoc.Isbn10,
|
||||
Isbn13: updatedDoc.Isbn13,
|
||||
Words: updatedDoc.Words,
|
||||
Filepath: updatedDoc.Filepath,
|
||||
CreatedAt: parseTime(updatedDoc.CreatedAt),
|
||||
UpdatedAt: parseTime(updatedDoc.UpdatedAt),
|
||||
Deleted: updatedDoc.Deleted,
|
||||
Percentage: percentage,
|
||||
Id: doc.ID,
|
||||
Title: *doc.Title,
|
||||
Author: *doc.Author,
|
||||
Description: doc.Description,
|
||||
Isbn10: doc.Isbn10,
|
||||
Isbn13: doc.Isbn13,
|
||||
Words: doc.Words,
|
||||
Filepath: doc.Filepath,
|
||||
Percentage: ptrOf(float32(doc.Percentage)),
|
||||
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
|
||||
Wpm: ptrOf(float32(doc.Wpm)),
|
||||
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
|
||||
LastRead: parseInterfaceTime(doc.LastRead),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Deleted: false,
|
||||
}
|
||||
|
||||
response := DocumentResponse{
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"ajv": "^8.18.0",
|
||||
"axios": "^1.13.6",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"orval": "8.5.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -46,10 +46,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
user: userData as { username: string; is_admin: boolean } | null,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
} else if (
|
||||
meError ||
|
||||
(meData && meData.status === 401)
|
||||
) {
|
||||
} else if (meError || (meData && meData.status === 401)) {
|
||||
// User is not authenticated or error occurred
|
||||
console.log('[AuthContext] User not authenticated:', meError?.message || String(meError));
|
||||
return {
|
||||
@@ -77,7 +74,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// The session cookie is automatically set by the browser
|
||||
setAuthState({
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
41
frontend/src/components/Field.tsx
Normal file
41
frontend/src/components/Field.tsx
Normal 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>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
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 { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
@@ -184,47 +184,7 @@ export default function HamburgerMenu() {
|
||||
href="https://gitea.va.reichard.io/evan/AnthoLume"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<svg
|
||||
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>
|
||||
<GitIcon size={20} />
|
||||
<span className="text-xs">{version}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
||||
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { UserIcon } from '../icons';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { UserIcon, DropdownIcon } from '../icons';
|
||||
import HamburgerMenu from './HamburgerMenu';
|
||||
|
||||
export default function Layout() {
|
||||
@@ -119,7 +118,7 @@ export default function Layout() {
|
||||
className="text-gray-800 transition-transform duration-200 dark:text-gray-200"
|
||||
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
<ChevronDown size={20} />
|
||||
<DropdownIcon size={20} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -62,9 +62,9 @@ export function Toast({ id, type, message, duration = 5000, onClose }: ToastProp
|
||||
}
|
||||
|
||||
const icons = {
|
||||
info: <Info size={20} className={iconStyles[type]} />,
|
||||
warning: <AlertTriangle size={20} className={iconStyles[type]} />,
|
||||
error: <XCircle size={20} className={iconStyles[type]} />,
|
||||
info: <InfoIcon size={20} className={iconStyles[type]} />,
|
||||
warning: <WarningIcon size={20} className={iconStyles[type]} />,
|
||||
error: <ErrorIcon size={20} className={iconStyles[type]} />,
|
||||
};
|
||||
|
||||
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]}`}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,3 +17,6 @@ export {
|
||||
PageLoader,
|
||||
InlineLoader,
|
||||
} from './Skeleton';
|
||||
|
||||
// Field components
|
||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
||||
|
||||
23
frontend/src/hooks/useDebounce.ts
Normal file
23
frontend/src/hooks/useDebounce.ts
Normal 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;
|
||||
}
|
||||
19
frontend/src/icons/BookIcon.tsx
Normal file
19
frontend/src/icons/BookIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/icons/CheckIcon.tsx
Normal file
19
frontend/src/icons/CheckIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/icons/CloseIcon.tsx
Normal file
19
frontend/src/icons/CloseIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/icons/ErrorIcon.tsx
Normal file
19
frontend/src/icons/ErrorIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/icons/FolderOpenIcon.tsx
Normal file
19
frontend/src/icons/FolderOpenIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
frontend/src/icons/GitIcon.tsx
Normal file
45
frontend/src/icons/GitIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/icons/WarningIcon.tsx
Normal file
19
frontend/src/icons/WarningIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -17,3 +17,10 @@ export { DropdownIcon } from './DropdownIcon';
|
||||
export { ClockIcon } from './ClockIcon';
|
||||
export { PasswordIcon } from './PasswordIcon';
|
||||
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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||
import { Button } from '../components/Button';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { FolderOpenIcon } from '../icons';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
|
||||
export default function AdminImportPage() {
|
||||
@@ -73,7 +73,7 @@ export default function AdminImportPage() {
|
||||
<form className="flex flex-col gap-4" onSubmit={handleImport}>
|
||||
<div className="flex w-full justify-between 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>
|
||||
</div>
|
||||
<div className="mr-4 flex flex-col justify-around gap-2">
|
||||
@@ -151,7 +151,7 @@ export default function AdminImportPage() {
|
||||
<tr key={item.name}>
|
||||
<td className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
|
||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||
<FolderOpen size={20} />
|
||||
<FolderOpenIcon size={20} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
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 { DeleteIcon, ActivityIcon, SearchIcon, DownloadIcon, EditIcon, InfoIcon } from '../icons';
|
||||
import { X, Check } from 'lucide-react';
|
||||
import {
|
||||
DeleteIcon,
|
||||
ActivityIcon,
|
||||
SearchIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
InfoIcon,
|
||||
CloseIcon,
|
||||
CheckIcon,
|
||||
} from '../icons';
|
||||
import { useState } from 'react';
|
||||
|
||||
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;
|
||||
}
|
||||
import { Field, FieldLabel, FieldValue, FieldActions } from '../components';
|
||||
|
||||
export default function DocumentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
||||
const { data: progressData, isLoading: progressLoading } = useGetProgress(id || '');
|
||||
const editMutation = useEditDocument();
|
||||
|
||||
const [showEditCover, setShowEditCover] = useState(false);
|
||||
@@ -53,7 +40,7 @@ export default function DocumentPage() {
|
||||
const [editAuthor, setEditAuthor] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
|
||||
if (docLoading || progressLoading) {
|
||||
if (docLoading) {
|
||||
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 progress =
|
||||
progressData?.status === 200 ? (progressData.data.progress as Progress | undefined) : undefined;
|
||||
docData?.status === 200 ? (docData.data.progress as Progress | undefined) : undefined;
|
||||
|
||||
if (!document) {
|
||||
return <div className="text-gray-500 dark:text-white">Document not found</div>;
|
||||
}
|
||||
|
||||
// Calculate total time left (mirroring legacy template logic)
|
||||
const percentage = progress?.percentage || document.percentage || 0;
|
||||
const percentage =
|
||||
document.percentage ?? (progress?.percentage ? progress.percentage * 100 : 0) ?? 0;
|
||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||
|
||||
@@ -90,7 +77,11 @@ export default function DocumentPage() {
|
||||
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),
|
||||
}
|
||||
);
|
||||
@@ -103,7 +94,11 @@ export default function DocumentPage() {
|
||||
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),
|
||||
}
|
||||
);
|
||||
@@ -116,21 +111,25 @@ export default function DocumentPage() {
|
||||
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),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-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="relative size-full">
|
||||
<div className="size-full overflow-scroll rounded bg-white p-4 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
{/* 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">
|
||||
{/* Cover Image with Edit Label */}
|
||||
<label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
|
||||
<img
|
||||
className="rounded object-fill w-full"
|
||||
className="w-full rounded object-fill"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
/>
|
||||
@@ -161,7 +160,7 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -172,25 +171,25 @@ export default function DocumentPage() {
|
||||
onChange={e => setShowEditCover(e.target.checked)}
|
||||
/>
|
||||
<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 ${
|
||||
showEditCover ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
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' : '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
|
||||
type="file"
|
||||
id="cover_file"
|
||||
name="cover_file"
|
||||
className="p-2 bg-gray-300"
|
||||
className="bg-gray-300 p-2"
|
||||
/>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</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
|
||||
type="checkbox"
|
||||
checked
|
||||
@@ -200,7 +199,7 @@ export default function DocumentPage() {
|
||||
/>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -219,14 +218,14 @@ export default function DocumentPage() {
|
||||
<DeleteIcon size={28} />
|
||||
</button>
|
||||
<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 ${
|
||||
showDelete ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
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' : '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
|
||||
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
|
||||
</button>
|
||||
@@ -254,18 +253,18 @@ export default function DocumentPage() {
|
||||
<SearchIcon size={28} />
|
||||
</button>
|
||||
<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 ${
|
||||
showIdentify ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
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' : '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
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder="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
|
||||
type="text"
|
||||
@@ -273,7 +272,7 @@ export default function DocumentPage() {
|
||||
name="author"
|
||||
placeholder="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
|
||||
type="text"
|
||||
@@ -281,11 +280,11 @@ export default function DocumentPage() {
|
||||
name="isbn"
|
||||
placeholder="ISBN 10 / ISBN 13"
|
||||
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
|
||||
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
|
||||
</button>
|
||||
@@ -314,221 +313,230 @@ export default function DocumentPage() {
|
||||
{/* Document Details Grid */}
|
||||
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
||||
{/* Title - Editable */}
|
||||
<div
|
||||
className={`relative rounded p-2 ${isEditingTitle ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700' : ''}`}
|
||||
<Field
|
||||
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 ? (
|
||||
<div className="relative flex gap-2 mt-1">
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
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>
|
||||
) : (
|
||||
<p className="font-medium text-lg">{document.title}</p>
|
||||
<FieldValue>{document.title}</FieldValue>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Author - Editable */}
|
||||
<div
|
||||
className={`relative rounded p-2 ${isEditingAuthor ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700' : ''}`}
|
||||
<Field
|
||||
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 ? (
|
||||
<div className="relative flex gap-2 mt-1">
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editAuthor}
|
||||
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>
|
||||
) : (
|
||||
<p className="font-medium text-lg">{document.author}</p>
|
||||
<FieldValue>{document.author}</FieldValue>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Time Read with Info Dropdown */}
|
||||
<div className="relative">
|
||||
<div className="relative inline-flex gap-2 text-gray-500">
|
||||
<p>Time Read</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTimeReadInfo(!showTimeReadInfo)}
|
||||
className="my-auto cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
aria-label="Show time read info"
|
||||
>
|
||||
<InfoIcon size={18} />
|
||||
</button>
|
||||
<div
|
||||
className={`absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg dark:bg-gray-600 ${
|
||||
showTimeReadInfo ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs flex">
|
||||
<p className="text-gray-400 w-32">Seconds / Percent</p>
|
||||
<p className="font-medium dark:text-white">
|
||||
{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}
|
||||
</p>
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<FieldLabel>Time Read</FieldLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTimeReadInfo(!showTimeReadInfo)}
|
||||
className="my-auto cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
aria-label="Show time read info"
|
||||
>
|
||||
<InfoIcon size={18} />
|
||||
</button>
|
||||
<div
|
||||
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="flex text-xs">
|
||||
<p className="w-32 text-gray-400">Seconds / Percent</p>
|
||||
<p className="font-medium dark:text-white">
|
||||
{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 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'}
|
||||
</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">
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FieldValue>
|
||||
{document.total_time_seconds && document.total_time_seconds > 0
|
||||
? formatDuration(document.total_time_seconds)
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</FieldValue>
|
||||
</Field>
|
||||
|
||||
{/* Progress */}
|
||||
<div>
|
||||
<p className="text-gray-500">Progress</p>
|
||||
<p className="font-medium text-lg">
|
||||
{percentage ? `${Math.round(percentage)}%` : '0%'}
|
||||
</p>
|
||||
</div>
|
||||
<Field label={<FieldLabel>Progress</FieldLabel>}>
|
||||
<FieldValue>{`${percentage.toFixed(2)}%`}</FieldValue>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Description - Editable */}
|
||||
<div
|
||||
className={`relative rounded p-2 ${isEditingDescription ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700' : ''}`}
|
||||
<Field
|
||||
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 ? (
|
||||
<div className="relative flex gap-2 mt-1">
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
<textarea
|
||||
value={editDescription}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative font-medium text-justify hyphens-auto mt-1">
|
||||
<p>{document.description || 'N/A'}</p>
|
||||
</div>
|
||||
<FieldValue className="hyphens-auto text-justify">
|
||||
{document.description || 'N/A'}
|
||||
</FieldValue>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata Section */}
|
||||
{/* TODO: Add metadata component when available */}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, FormEvent, useRef } from 'react';
|
||||
import { useState, FormEvent, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import type { DocumentsResponse } from '../generated/model/documentsResponse';
|
||||
import { ActivityIcon, DownloadIcon, SearchIcon, UploadIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
|
||||
interface DocumentCardProps {
|
||||
doc: {
|
||||
@@ -87,11 +89,18 @@ export default function DocumentsPage() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
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 docs = data?.data?.documents;
|
||||
const previousPage = data?.data?.previous_page;
|
||||
const nextPage = data?.data?.next_page;
|
||||
const docs = (data?.data as DocumentsResponse | undefined)?.documents;
|
||||
const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
|
||||
const nextPage = (data?.data as DocumentsResponse | undefined)?.next_page;
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -128,10 +137,6 @@ export default function DocumentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search Form */}
|
||||
@@ -162,9 +167,11 @@ export default function DocumentsPage() {
|
||||
|
||||
{/* 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} />
|
||||
))}
|
||||
{isLoading ? (
|
||||
<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>
|
||||
|
||||
{/* Pagination */}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||
import { SearchIcon, DownloadIcon } from '../icons';
|
||||
import { Book } from 'lucide-react';
|
||||
import { SearchIcon, DownloadIcon, BookIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
export default function SearchPage() {
|
||||
@@ -39,7 +38,7 @@ export default function SearchPage() {
|
||||
</div>
|
||||
<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">
|
||||
<Book size={15} />
|
||||
<BookIcon size={15} />
|
||||
</span>
|
||||
<select
|
||||
value={source}
|
||||
|
||||
Reference in New Issue
Block a user