wip 22
This commit is contained in:
@@ -47,6 +47,7 @@ Regenerate:
|
||||
- The Go server embeds `templates/*` and `assets/*`.
|
||||
- Root Tailwind output is built to `assets/style.css`.
|
||||
- Be mindful of whether a change affects the embedded server-rendered app, the React frontend, or both.
|
||||
- SQLite timestamps are stored as RFC3339 strings (usually with a trailing `Z`); prefer `parseTime` / `parseTimePtr` instead of ad-hoc `time.Parse` layouts.
|
||||
|
||||
## 5) Frontend
|
||||
|
||||
|
||||
@@ -438,11 +438,10 @@ func (s *Server) GetUsers(ctx context.Context, request GetUsersRequestObject) (G
|
||||
|
||||
apiUsers := make([]User, len(users))
|
||||
for i, user := range users {
|
||||
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
|
||||
apiUsers[i] = User{
|
||||
Id: user.ID,
|
||||
Admin: user.Admin,
|
||||
CreatedAt: createdAt,
|
||||
CreatedAt: parseTime(user.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,11 +492,10 @@ func (s *Server) UpdateUser(ctx context.Context, request UpdateUserRequestObject
|
||||
|
||||
apiUsers := make([]User, len(users))
|
||||
for i, user := range users {
|
||||
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
|
||||
apiUsers[i] = User{
|
||||
Id: user.ID,
|
||||
Admin: user.Admin,
|
||||
CreatedAt: createdAt,
|
||||
CreatedAt: parseTime(user.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Also follow the repository root guide at `../AGENTS.md`.
|
||||
- Do not add external icon libraries.
|
||||
- Prefer generated types from `src/generated/model/` over `any`.
|
||||
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them.
|
||||
- For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc.
|
||||
- Prefer `LoadingState` for result-area loading indicators; avoid early returns that unmount search/filter forms during fetches.
|
||||
|
||||
## 3) Generated API client
|
||||
|
||||
@@ -11,13 +11,13 @@ type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { h
|
||||
|
||||
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
||||
const baseClass =
|
||||
'transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white';
|
||||
'transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
if (variant === 'secondary') {
|
||||
return `${baseClass} bg-black shadow-md hover:text-black hover:bg-white`;
|
||||
return `${baseClass} bg-black shadow-md hover:text-black hover:bg-white disabled:hover:text-white disabled:hover:bg-black`;
|
||||
}
|
||||
|
||||
return `${baseClass} bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100`;
|
||||
return `${baseClass} bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:hover:bg-gray-500 dark:disabled:hover:bg-transparent`;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
|
||||
@@ -4,6 +4,7 @@ interface BaseIconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
hoverable?: boolean;
|
||||
viewBox?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -12,12 +13,15 @@ export function BaseIcon({
|
||||
size = 24,
|
||||
className = '',
|
||||
disabled = false,
|
||||
hoverable = true,
|
||||
viewBox = '0 0 24 24',
|
||||
children,
|
||||
}: BaseIconProps) {
|
||||
const disabledClasses = disabled
|
||||
? 'text-gray-200 dark:text-gray-600'
|
||||
: 'hover:text-gray-800 dark:hover:text-gray-100';
|
||||
: hoverable
|
||||
? 'hover:text-gray-800 dark:hover:text-gray-100'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -4,11 +4,12 @@ interface Search2IconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
hoverable?: boolean;
|
||||
}
|
||||
|
||||
export function Search2Icon({ size = 24, className = '', disabled = false }: Search2IconProps) {
|
||||
export function Search2Icon({ size = 24, className = '', disabled = false, hoverable = true }: Search2IconProps) {
|
||||
return (
|
||||
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||
<BaseIcon size={size} className={className} disabled={disabled} hoverable={hoverable}>
|
||||
<rect width="24" height="24" fill="none" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function AdminLogsPage() {
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<Search2Icon size={15} />
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -12,7 +12,7 @@ interface BackupTypes {
|
||||
export default function AdminPage() {
|
||||
const { isLoading } = useGetAdmin();
|
||||
const postAdminAction = usePostAdminAction();
|
||||
const { showInfo, showError } = useToasts();
|
||||
const { showInfo, showError, removeToast } = useToasts();
|
||||
|
||||
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
||||
covers: false,
|
||||
@@ -83,26 +83,32 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreSubmit = (e: FormEvent) => {
|
||||
const handleRestoreSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!restoreFile) return;
|
||||
|
||||
postAdminAction.mutate(
|
||||
{
|
||||
const startedToastId = showInfo('Restore started', 0);
|
||||
|
||||
try {
|
||||
const response = await postAdminAction.mutateAsync({
|
||||
data: {
|
||||
action: 'RESTORE',
|
||||
restore_file: restoreFile,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
});
|
||||
|
||||
removeToast(startedToastId);
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
showInfo('Restore completed successfully');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Restore failed: ' + getErrorMessage(error));
|
||||
},
|
||||
return;
|
||||
}
|
||||
|
||||
showError('Restore failed: ' + getErrorMessage(response.data));
|
||||
} catch (error) {
|
||||
removeToast(startedToastId);
|
||||
showError('Restore failed: ' + getErrorMessage(error));
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleMetadataMatch = () => {
|
||||
@@ -191,7 +197,7 @@ export default function AdminPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="h-10 w-40">
|
||||
<Button variant="secondary" type="submit">
|
||||
<Button variant="secondary" type="submit" disabled={!restoreFile}>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,56 @@
|
||||
import { useState, FormEvent, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import type { Document, DocumentsResponse } from '../generated/model';
|
||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { LoadingState } from '../components';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
const DOCUMENTS_VIEW_MODE_KEY = 'documents:view-mode';
|
||||
|
||||
type DocumentViewMode = 'grid' | 'list';
|
||||
|
||||
function getInitialViewMode(): DocumentViewMode {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'grid';
|
||||
}
|
||||
|
||||
const storedValue = window.localStorage.getItem(DOCUMENTS_VIEW_MODE_KEY);
|
||||
return storedValue === 'list' ? 'list' : 'grid';
|
||||
}
|
||||
|
||||
interface DocumentCardProps {
|
||||
doc: Document;
|
||||
}
|
||||
|
||||
function DocumentCard({ doc }: DocumentCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const percentage = doc.percentage || 0;
|
||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="flex size-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="flex size-full cursor-pointer gap-4 rounded bg-white p-4 shadow-lg transition-colors hover:bg-gray-50 focus:outline-none dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => navigate(`/documents/${doc.id}`)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
navigate(`/documents/${doc.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative my-auto h-48 min-w-fit">
|
||||
<Link to={`/documents/${doc.id}`}>
|
||||
<img
|
||||
className="h-full rounded object-cover"
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={doc.title}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-around text-sm dark:text-white">
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
@@ -57,11 +79,70 @@ function DocumentCard({ doc }: DocumentCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-gray-500 dark:text-gray-400">
|
||||
<Link to={`/activity?document=${doc.id}`}>
|
||||
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
|
||||
<ActivityIcon size={20} />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<a href={`/api/v1/documents/${doc.id}/file`}>
|
||||
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
||||
<DownloadIcon size={20} />
|
||||
</a>
|
||||
) : (
|
||||
<DownloadIcon size={20} disabled />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentListItemProps {
|
||||
doc: Document;
|
||||
}
|
||||
|
||||
function DocumentListItem({ doc }: DocumentListItemProps) {
|
||||
const navigate = useNavigate();
|
||||
const percentage = doc.percentage || 0;
|
||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="block cursor-pointer rounded bg-white p-4 shadow-lg transition-colors hover:bg-gray-50 focus:outline-none dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
|
||||
onClick={() => navigate(`/documents/${doc.id}`)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
navigate(`/documents/${doc.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-gray-400">Title</p>
|
||||
<p className="font-medium">{doc.title || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400">Author</p>
|
||||
<p className="font-medium">{doc.author || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400">Progress</p>
|
||||
<p className="font-medium">{percentage}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400">Time Read</p>
|
||||
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end gap-4 text-gray-500 dark:text-gray-400">
|
||||
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
|
||||
<ActivityIcon size={20} />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
||||
<DownloadIcon size={20} />
|
||||
</a>
|
||||
) : (
|
||||
@@ -78,11 +159,16 @@ export default function DocumentsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(9);
|
||||
const [uploadMode, setUploadMode] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<DocumentViewMode>(getInitialViewMode);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showInfo, showWarning, showError } = useToasts();
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(DOCUMENTS_VIEW_MODE_KEY, viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
// Reset to page 1 when search changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
@@ -94,11 +180,6 @@ export default function DocumentsPage() {
|
||||
const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
|
||||
const nextPage = (data?.data as DocumentsResponse | undefined)?.next_page;
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -131,13 +212,12 @@ export default function DocumentsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search Form */}
|
||||
<div className="mb-4 flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
||||
<div className="flex grow flex-col gap-4 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<Search2Icon size={15} />
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
@@ -149,24 +229,59 @@ export default function DocumentsPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<Button variant="secondary" type="submit">
|
||||
Search
|
||||
</Button>
|
||||
<div className="inline-flex rounded border border-gray-300 bg-white p-1 dark:border-gray-600 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`rounded px-3 py-1 text-sm font-medium transition-colors ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-gray-800 text-white dark:bg-gray-100 dark:text-gray-900'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`rounded px-3 py-1 text-sm font-medium transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'bg-gray-800 text-white dark:bg-gray-100 dark:text-gray-900'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Document Grid */}
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{isLoading ? (
|
||||
<LoadingState className="col-span-full min-h-48" />
|
||||
) : docs && docs.length > 0 ? (
|
||||
docs.map(doc => <DocumentCard key={doc.id} doc={doc} />)
|
||||
) : (
|
||||
docs?.map(doc => <DocumentCard key={doc.id} doc={doc} />)
|
||||
<div className="col-span-full rounded bg-white p-6 text-center text-gray-500 shadow-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
No documents found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? (
|
||||
<LoadingState className="min-h-48" />
|
||||
) : docs && docs.length > 0 ? (
|
||||
docs.map(doc => <DocumentListItem key={doc.id} doc={doc} />)
|
||||
) : (
|
||||
<div className="rounded bg-white p-6 text-center text-gray-500 shadow-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
No documents found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="mt-4 flex w-full justify-center gap-4 text-black dark:text-white">
|
||||
{previousPage && previousPage > 0 && (
|
||||
<button
|
||||
@@ -186,7 +301,6 @@ export default function DocumentsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -54,7 +54,7 @@ export function SearchPageView({
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<Search2Icon size={15} />
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
Reference in New Issue
Block a user