wip 22
This commit is contained in:
@@ -47,6 +47,7 @@ Regenerate:
|
|||||||
- The Go server embeds `templates/*` and `assets/*`.
|
- The Go server embeds `templates/*` and `assets/*`.
|
||||||
- Root Tailwind output is built to `assets/style.css`.
|
- 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.
|
- 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
|
## 5) Frontend
|
||||||
|
|
||||||
|
|||||||
@@ -438,11 +438,10 @@ func (s *Server) GetUsers(ctx context.Context, request GetUsersRequestObject) (G
|
|||||||
|
|
||||||
apiUsers := make([]User, len(users))
|
apiUsers := make([]User, len(users))
|
||||||
for i, user := range users {
|
for i, user := range users {
|
||||||
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
|
|
||||||
apiUsers[i] = User{
|
apiUsers[i] = User{
|
||||||
Id: user.ID,
|
Id: user.ID,
|
||||||
Admin: user.Admin,
|
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))
|
apiUsers := make([]User, len(users))
|
||||||
for i, user := range users {
|
for i, user := range users {
|
||||||
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
|
|
||||||
apiUsers[i] = User{
|
apiUsers[i] = User{
|
||||||
Id: user.ID,
|
Id: user.ID,
|
||||||
Admin: user.Admin,
|
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.
|
- Do not add external icon libraries.
|
||||||
- Prefer generated types from `src/generated/model/` over `any`.
|
- 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.
|
- 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.
|
- Prefer `LoadingState` for result-area loading indicators; avoid early returns that unmount search/filter forms during fetches.
|
||||||
|
|
||||||
## 3) Generated API client
|
## 3) Generated API client
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { h
|
|||||||
|
|
||||||
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
||||||
const baseClass =
|
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') {
|
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>(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ interface BaseIconProps {
|
|||||||
size?: number;
|
size?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
hoverable?: boolean;
|
||||||
viewBox?: string;
|
viewBox?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -12,12 +13,15 @@ export function BaseIcon({
|
|||||||
size = 24,
|
size = 24,
|
||||||
className = '',
|
className = '',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
hoverable = true,
|
||||||
viewBox = '0 0 24 24',
|
viewBox = '0 0 24 24',
|
||||||
children,
|
children,
|
||||||
}: BaseIconProps) {
|
}: BaseIconProps) {
|
||||||
const disabledClasses = disabled
|
const disabledClasses = disabled
|
||||||
? 'text-gray-200 dark:text-gray-600'
|
? '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 (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ interface Search2IconProps {
|
|||||||
size?: number;
|
size?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
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 (
|
return (
|
||||||
<BaseIcon size={size} className={className} disabled={disabled}>
|
<BaseIcon size={size} className={className} disabled={disabled} hoverable={hoverable}>
|
||||||
<rect width="24" height="24" fill="none" />
|
<rect width="24" height="24" fill="none" />
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function AdminLogsPage() {
|
|||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<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">
|
<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>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface BackupTypes {
|
|||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { isLoading } = useGetAdmin();
|
const { isLoading } = useGetAdmin();
|
||||||
const postAdminAction = usePostAdminAction();
|
const postAdminAction = usePostAdminAction();
|
||||||
const { showInfo, showError } = useToasts();
|
const { showInfo, showError, removeToast } = useToasts();
|
||||||
|
|
||||||
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
||||||
covers: false,
|
covers: false,
|
||||||
@@ -83,26 +83,32 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestoreSubmit = (e: FormEvent) => {
|
const handleRestoreSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!restoreFile) return;
|
if (!restoreFile) return;
|
||||||
|
|
||||||
postAdminAction.mutate(
|
const startedToastId = showInfo('Restore started', 0);
|
||||||
{
|
|
||||||
|
try {
|
||||||
|
const response = await postAdminAction.mutateAsync({
|
||||||
data: {
|
data: {
|
||||||
action: 'RESTORE',
|
action: 'RESTORE',
|
||||||
restore_file: restoreFile,
|
restore_file: restoreFile,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
{
|
|
||||||
onSuccess: () => {
|
removeToast(startedToastId);
|
||||||
showInfo('Restore completed successfully');
|
|
||||||
},
|
if (response.status >= 200 && response.status < 300) {
|
||||||
onError: error => {
|
showInfo('Restore completed successfully');
|
||||||
showError('Restore failed: ' + getErrorMessage(error));
|
return;
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
showError('Restore failed: ' + getErrorMessage(response.data));
|
||||||
|
} catch (error) {
|
||||||
|
removeToast(startedToastId);
|
||||||
|
showError('Restore failed: ' + getErrorMessage(error));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMetadataMatch = () => {
|
const handleMetadataMatch = () => {
|
||||||
@@ -191,7 +197,7 @@ export default function AdminPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-10 w-40">
|
<div className="h-10 w-40">
|
||||||
<Button variant="secondary" type="submit">
|
<Button variant="secondary" type="submit" disabled={!restoreFile}>
|
||||||
Restore
|
Restore
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,56 @@
|
|||||||
import { useState, FormEvent, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Document, DocumentsResponse } from '../generated/model';
|
import type { Document, DocumentsResponse } from '../generated/model';
|
||||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||||
import { Button } from '../components/Button';
|
|
||||||
import { LoadingState } from '../components';
|
import { LoadingState } from '../components';
|
||||||
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';
|
import { useDebounce } from '../hooks/useDebounce';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
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 {
|
interface DocumentCardProps {
|
||||||
doc: Document;
|
doc: Document;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocumentCard({ doc }: DocumentCardProps) {
|
function DocumentCard({ doc }: DocumentCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const percentage = doc.percentage || 0;
|
const percentage = doc.percentage || 0;
|
||||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<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">
|
<div className="relative my-auto h-48 min-w-fit">
|
||||||
<Link to={`/documents/${doc.id}`}>
|
<img
|
||||||
<img
|
className="h-full rounded object-cover"
|
||||||
className="h-full rounded object-cover"
|
src={`/api/v1/documents/${doc.id}/cover`}
|
||||||
src={`/api/v1/documents/${doc.id}/cover`}
|
alt={doc.title}
|
||||||
alt={doc.title}
|
/>
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col justify-around text-sm dark:text-white">
|
<div className="flex w-full flex-col justify-around text-sm dark:text-white">
|
||||||
<div className="inline-flex shrink-0 items-center">
|
<div className="inline-flex shrink-0 items-center">
|
||||||
@@ -57,11 +79,70 @@ function DocumentCard({ doc }: DocumentCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-gray-500 dark:text-gray-400">
|
<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} />
|
<ActivityIcon size={20} />
|
||||||
</Link>
|
</Link>
|
||||||
{doc.filepath ? (
|
{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} />
|
<DownloadIcon size={20} />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
@@ -78,11 +159,16 @@ export default function DocumentsPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [limit] = useState(9);
|
const [limit] = useState(9);
|
||||||
const [uploadMode, setUploadMode] = useState(false);
|
const [uploadMode, setUploadMode] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<DocumentViewMode>(getInitialViewMode);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { showInfo, showWarning, showError } = useToasts();
|
const { showInfo, showWarning, showError } = useToasts();
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(DOCUMENTS_VIEW_MODE_KEY, viewMode);
|
||||||
|
}, [viewMode]);
|
||||||
|
|
||||||
// Reset to page 1 when search changes
|
// Reset to page 1 when search changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -94,11 +180,6 @@ export default function DocumentsPage() {
|
|||||||
const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
|
const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
|
||||||
const nextPage = (data?.data as DocumentsResponse | undefined)?.next_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 handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -131,13 +212,12 @@ export default function DocumentsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Search Form */}
|
<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="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">
|
<div className="flex flex-col gap-4 lg:flex-row">
|
||||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
|
||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<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">
|
<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>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -149,24 +229,59 @@ export default function DocumentsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:w-60">
|
<div className="inline-flex rounded border border-gray-300 bg-white p-1 dark:border-gray-600 dark:bg-gray-800">
|
||||||
<Button variant="secondary" type="submit">
|
<button
|
||||||
Search
|
type="button"
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Document Grid */}
|
{viewMode === '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">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingState className="col-span-full min-h-48" />
|
<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>
|
<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">
|
<div className="mt-4 flex w-full justify-center gap-4 text-black dark:text-white">
|
||||||
{previousPage && previousPage > 0 && (
|
{previousPage && previousPage > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -186,7 +301,6 @@ export default function DocumentsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload Button */}
|
|
||||||
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
|
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function SearchPageView({
|
|||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<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">
|
<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>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
Reference in New Issue
Block a user