Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions client/src/app/routes/app/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,65 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';

import { DashboardLayout } from '@/components/layouts/DashboardLayout';
import DashboardMain from '@/features/Dashboard/components/DashboardMain/dashboard-main';
import { api } from '@/lib/api';
import { Document } from '@/types/api';

const DEFAULT_LIMIT = 20;

/**
* Dashboard page listing owned documents alongside shared ones with view-mode controls.
*/
export default function Dashboard() {
const [ownedDocs, setOwnedDocs] = useState<Document[]>([]);
const [collaboratedDocs, setCollaboratedDocs] = useState<Document[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [limit] = useState(DEFAULT_LIMIT);
const [offset, setOffset] = useState(0);
const [pagination, setPagination] = useState<{
totalOwned: number;
totalCollaborated: number;
} | null>(null);

useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => clearTimeout(timer);
}, [search]);

useEffect(() => {
setOffset(0);
}, [debouncedSearch]);

const fetchDocs = async () => {
const fetchDocs = useCallback(async () => {
try {
const res = await api.get('/document');
// console.log(res);
setLoading(true);
const res = await api.get('/document', {
params: {
q: debouncedSearch || undefined,
limit,
offset,
},
});
setOwnedDocs(res.data.owned || []);
setCollaboratedDocs(res.data.collaborated || []);
if (res.data.pagination) {
setPagination({
totalOwned: res.data.pagination.totalOwned,
totalCollaborated: res.data.pagination.totalCollaborated,
});
}
} catch (err) {
console.error('Failed to fetch documents', err);
} finally {
setLoading(false);
}
};
}, [debouncedSearch, limit, offset]);

useEffect(() => {
fetchDocs();
}, []);
}, [fetchDocs]);

return (
<DashboardLayout title="Dashboard">
Expand All @@ -37,6 +68,12 @@ export default function Dashboard() {
collaboratedDocs={collaboratedDocs}
loading={loading}
setDocuments={setOwnedDocs}
search={search}
onSearchChange={setSearch}
limit={limit}
offset={offset}
onOffsetChange={setOffset}
pagination={pagination}
/>
</DashboardLayout>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { useEffect, useMemo, useState } from 'react';
import {
LuChevronLeft as ChevronLeftIcon,
LuChevronRight as ChevronRightIcon,
LuFileText as FileIcon,
LuPin as PinIcon,
LuSearch as SearchIcon,
LuShare as ShareIcon,
} from 'react-icons/lu';

import { Button } from '@/components/ui/Button';
import { Document } from '@/types/api';

import NewDocumentModal from '../NewDocumentModal/new-document-modal';
Expand Down Expand Up @@ -33,6 +37,18 @@ type DashboardMainProps = {
loading: boolean;
/** State setter used to apply card-level updates and deletions. */
setDocuments: React.Dispatch<React.SetStateAction<Document[]>>;
/** Current search query. */
search: string;
/** Called when search query changes. */
onSearchChange: (value: string) => void;
/** Page size. */
limit: number;
/** Current offset. */
offset: number;
/** Called when offset changes. */
onOffsetChange: (offset: number) => void;
/** Total counts returned by the server. */
pagination: { totalOwned: number; totalCollaborated: number } | null;
};

/**
Expand Down Expand Up @@ -67,6 +83,12 @@ export default function DashboardMain({
collaboratedDocs,
loading,
setDocuments,
search,
onSearchChange,
limit,
offset,
onOffsetChange,
pagination,
}: DashboardMainProps) {
const [view, setView] = useState<'grid' | 'row'>('grid');
const [showSkeletons, setShowSkeletons] = useState(true);
Expand Down Expand Up @@ -107,18 +129,44 @@ export default function DashboardMain({
setDocuments((docs) => docs.filter((doc) => doc.id !== id));
};

const totalOwned = pagination?.totalOwned ?? ownedDocs.length;
const totalCollaborated =
pagination?.totalCollaborated ?? collaboratedDocs.length;
const maxTotal = Math.max(totalOwned, totalCollaborated);
const canPrev = offset > 0;
const canNext = offset + limit < maxTotal;
const page = Math.floor(offset / limit) + 1;
const totalPages = Math.max(1, Math.ceil(maxTotal / limit));

return (
<>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-xl font-semibold">Dashboard</h1>
<div className="flex items-center gap-2">
<DashboardViewToggle setView={setView} />
<SortControl
value={sort}
onChange={(val: SortValue) => setSort(val)}
options={sortOptions}
<div className="mb-6 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Dashboard</h1>
<div className="flex items-center gap-2">
<DashboardViewToggle setView={setView} />
<SortControl
value={sort}
onChange={(val: SortValue) => setSort(val)}
options={sortOptions}
/>
<NewDocumentModal setDocuments={setDocuments} />
</div>
</div>
<div className="relative max-w-sm">
<SearchIcon
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
type="search"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search by title..."
aria-label="Search documents"
className="flex h-9 w-full rounded-md border border-input bg-transparent py-1 pl-9 pr-3 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<NewDocumentModal setDocuments={setDocuments} />
</div>
</div>

Expand Down Expand Up @@ -191,6 +239,35 @@ export default function DashboardMain({
/>
</DocumentSection>
)}
{!showSkeletons &&
(ownedDocs.length > 0 || collaboratedDocs.length > 0) && (
<div className="mt-6 flex items-center justify-between border-t pt-4">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages} · {totalOwned} owned ·{' '}
{totalCollaborated} shared
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!canPrev}
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
aria-label="Previous page"
>
<ChevronLeftIcon size={16} /> Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={!canNext}
onClick={() => onOffsetChange(offset + limit)}
aria-label="Next page"
>
Next <ChevronRightIcon size={16} />
</Button>
</div>
</div>
)}
</>
)}
</>
Expand Down
72 changes: 54 additions & 18 deletions server/src/controllers/document.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,47 +136,83 @@ export const getDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res: R
}
});

/**
* Fields returned by the dashboard list — excludes `content` (Yjs-owned,
* potentially large) and `YjsDocumentState`. Only metadata needed to render
* cards.
*/
const documentListSelect = {
id: true,
title: true,
isPublic: true,
shareId: true,
allowSelfJoin: true,
authorId: true,
createdAt: true,
updatedAt: true,
} as const;

export const getDocs = asyncErrorWrapper(async (req: AuthenticatedRequest, res: Response) => {
const clientInfo = getClientInfo(req);
const userId = req.user?.userId;
const { q, limit, offset } = req.query as unknown as { q?: string; limit: number; offset: number };

logger.debug('Documents list request', {
action: 'GET_DOCUMENTS_ATTEMPT',
...clientInfo,
userId,
q,
limit,
offset,
});

try {
const ownedDocs = await prisma.document.findMany({
where: { authorId: userId },
orderBy: { updatedAt: 'desc' },
});

const collaboratedDocs = await prisma.document.findMany({
where: {
Collaborator: {
some: {
userId,
},
},
NOT: {
authorId: userId, // exclude owned docs
},
},
orderBy: { updatedAt: 'desc' },
});
const titleFilter = q ? { title: { contains: q, mode: 'insensitive' as const } } : {};

const ownedWhere: Prisma.DocumentWhereInput = {
authorId: userId,
...titleFilter,
};

const collaboratedWhere: Prisma.DocumentWhereInput = {
Collaborator: { some: { userId } },
NOT: { authorId: userId },
...titleFilter,
};

const [ownedDocs, collaboratedDocs, totalOwned, totalCollaborated] = await Promise.all([
prisma.document.findMany({
where: ownedWhere,
orderBy: { updatedAt: 'desc' },
select: documentListSelect,
take: limit,
skip: offset,
}),
prisma.document.findMany({
where: collaboratedWhere,
orderBy: { updatedAt: 'desc' },
select: documentListSelect,
take: limit,
skip: offset,
}),
prisma.document.count({ where: ownedWhere }),
prisma.document.count({ where: collaboratedWhere }),
]);

logger.debug('Documents list retrieved successfully', {
action: 'GET_DOCUMENTS_SUCCESS',
...clientInfo,
userId,
ownedCount: ownedDocs.length,
collaboratedCount: collaboratedDocs.length,
totalOwned,
totalCollaborated,
});

res.status(StatusCodes.OK).json({
owned: ownedDocs,
collaborated: collaboratedDocs,
pagination: { limit, offset, totalOwned, totalCollaborated },
});
} catch (error) {
logger.error('Documents list retrieval failed', {
Expand Down
3 changes: 2 additions & 1 deletion server/src/routers/document.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { CreateDocumentSchema } from '@/validations/createDocument.schema';
import {
CollaboratorParamsSchema,
IdParamsSchema,
ListDocumentsQuerySchema,
RequestIdParamsSchema,
ShareLinkQuerySchema,
} from '@/validations/documentParams.schema';
Expand All @@ -35,7 +36,7 @@ docRouter.use(authenticate);
docRouter.get('/share/:token', getDocByToken);

docRouter.post('/', validate({ body: CreateDocumentSchema }), createDoc);
docRouter.get('/', getDocs);
docRouter.get('/', validate({ query: ListDocumentsQuerySchema }), getDocs);
docRouter.get('/:id', validate({ params: IdParamsSchema }), getDoc);
docRouter.put('/:id', validate({ params: IdParamsSchema, body: UpdateDocumentSchema }), updateDoc);
docRouter.delete('/:id', validate({ params: IdParamsSchema }), deleteDoc);
Expand Down
11 changes: 11 additions & 0 deletions server/src/validations/documentParams.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,14 @@ export const ShareLinkQuerySchema = z.object({
});

export type ShareLinkQuerySchema = z.infer<typeof ShareLinkQuerySchema>;

export const ListDocumentsQuerySchema = z.object({
q: z.preprocess(
v => (typeof v === 'string' && v.trim() === '' ? undefined : v),
z.string().trim().min(1).max(100).optional()
),
limit: z.coerce.number().int().min(1).max(50).default(20),
offset: z.coerce.number().int().min(0).default(0),
});

export type ListDocumentsQuerySchema = z.infer<typeof ListDocumentsQuerySchema>;
Loading
Loading