diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index e99f4b761..03e10c53e 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1297,5 +1297,10 @@ "Select version from {{date}}": "Select version from {{date}}", "Version actions for {{date}}": "Version actions for {{date}}", "Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}", - "Exit compare": "Exit compare" + "Exit compare": "Exit compare", + "Search attachments...": "Search attachments...", + "Error loading attachments.": "Error loading attachments.", + "No attachments on this page yet.": "No attachments on this page yet.", + "Uploaded by {{name}}": "Uploaded by {{name}}", + "Download {{name}}": "Download {{name}}" } diff --git a/apps/client/src/features/attachments/components/attachment-file-icon.tsx b/apps/client/src/features/attachments/components/attachment-file-icon.tsx new file mode 100644 index 000000000..b28e8de2c --- /dev/null +++ b/apps/client/src/features/attachments/components/attachment-file-icon.tsx @@ -0,0 +1,59 @@ +import { ThemeIcon } from "@mantine/core"; +import { + IconFile, + IconFileTypeCsv, + IconFileTypeDocx, + IconFileTypePdf, + IconFileTypePpt, + IconFileTypeXls, + IconFileZip, + IconMovie, + IconMusic, + IconPhoto, + type Icon, +} from "@tabler/icons-react"; + +const EXT_ICONS: Record = { + ".pdf": { icon: IconFileTypePdf, color: "red" }, + ".doc": { icon: IconFileTypeDocx, color: "blue" }, + ".docx": { icon: IconFileTypeDocx, color: "blue" }, + ".xls": { icon: IconFileTypeXls, color: "teal" }, + ".xlsx": { icon: IconFileTypeXls, color: "teal" }, + ".csv": { icon: IconFileTypeCsv, color: "teal" }, + ".ppt": { icon: IconFileTypePpt, color: "orange" }, + ".pptx": { icon: IconFileTypePpt, color: "orange" }, + ".zip": { icon: IconFileZip, color: "gray" }, + ".rar": { icon: IconFileZip, color: "gray" }, + ".7z": { icon: IconFileZip, color: "gray" }, + ".tar": { icon: IconFileZip, color: "gray" }, + ".gz": { icon: IconFileZip, color: "gray" }, +}; + +const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [ + { prefix: "image/", icon: IconPhoto, color: "grape" }, + { prefix: "video/", icon: IconMovie, color: "violet" }, + { prefix: "audio/", icon: IconMusic, color: "pink" }, +]; + +interface AttachmentFileIconProps { + fileExt?: string; + mimeType?: string; +} + +export function AttachmentFileIcon({ + fileExt, + mimeType, +}: AttachmentFileIconProps) { + const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined; + const byMime = mimeType + ? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix)) + : undefined; + const { icon: FileIcon, color } = byExt ?? + byMime ?? { icon: IconFile, color: "gray" }; + + return ( + + + + ); +} diff --git a/apps/client/src/features/attachments/components/page-attachments-modal.tsx b/apps/client/src/features/attachments/components/page-attachments-modal.tsx new file mode 100644 index 000000000..53189b749 --- /dev/null +++ b/apps/client/src/features/attachments/components/page-attachments-modal.tsx @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActionIcon, + Anchor, + Center, + Group, + Loader, + Modal, + ScrollArea, + Text, + Tooltip, +} from "@mantine/core"; +import { IconDownload } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { SearchInput } from "@/components/common/search-input.tsx"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts"; +import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts"; +import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx"; +import { formatBytes } from "@/lib"; +import { getFileUrl } from "@/lib/config.ts"; +import { formattedDate } from "@/lib/time.ts"; + +interface PageAttachmentsModalProps { + pageId: string; + open: boolean; + onClose: () => void; +} + +export default function PageAttachmentsModal({ + pageId, + open, + onClose, +}: PageAttachmentsModalProps) { + const { t } = useTranslation(); + + return ( + + + + ); +} + +function PageAttachmentsList({ pageId }: { pageId: string }) { + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + const { + data, + isLoading, + isError, + isFetching, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = usePageAttachmentsQuery(pageId, search); + + const attachments = useMemo( + () => data?.pages.flatMap((page) => page.items) ?? [], + [data], + ); + + const loadMoreRef = useRef(null); + + useEffect(() => { + const sentinel = loadMoreRef.current; + if (!sentinel || !hasNextPage) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isFetching) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetching]); + + const handleSearch = useCallback((value: string) => setSearch(value), []); + + return ( + <> + + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ + {t("Error loading attachments.")} + +
+ ) : attachments.length === 0 ? ( +
+ + {search + ? t("No results found") + : t("No attachments on this page yet.")} + +
+ ) : ( + + {attachments.map((attachment) => ( + + ))} + {hasNextPage &&
} + {isFetchingNextPage && ( +
+ +
+ )} + + )} + + ); +} + +function AttachmentRow({ attachment }: { attachment: IPageAttachment }) { + const { t } = useTranslation(); + const fileUrl = getFileUrl(attachment.url); + + return ( + + + +
+ + {attachment.fileName} + + + {formatBytes(Number(attachment.fileSize))} + {" ยท "} + {formattedDate(new Date(attachment.createdAt))} + +
+ + {attachment.creator && ( + + + + )} + + + + + + +
+ ); +} diff --git a/apps/client/src/features/attachments/queries/attachment-query.ts b/apps/client/src/features/attachments/queries/attachment-query.ts new file mode 100644 index 000000000..e9630bdd6 --- /dev/null +++ b/apps/client/src/features/attachments/queries/attachment-query.ts @@ -0,0 +1,25 @@ +import { + InfiniteData, + keepPreviousData, + useInfiniteQuery, + UseInfiniteQueryResult, +} from "@tanstack/react-query"; +import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts"; +import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts"; +import { IPagination } from "@/lib/types.ts"; + +export function usePageAttachmentsQuery( + pageId: string, + search?: string, +): UseInfiniteQueryResult, unknown>> { + return useInfiniteQuery({ + queryKey: ["page-attachments", pageId, search], + queryFn: ({ pageParam }) => + getPageAttachments(pageId, { cursor: pageParam, query: search }), + enabled: !!pageId, + gcTime: 0, + placeholderData: keepPreviousData, + initialPageParam: undefined, + getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined, + }); +} diff --git a/apps/client/src/features/attachments/services/attachment-service.ts b/apps/client/src/features/attachments/services/attachment-service.ts index fa43da3ca..e129c248e 100644 --- a/apps/client/src/features/attachments/services/attachment-service.ts +++ b/apps/client/src/features/attachments/services/attachment-service.ts @@ -3,7 +3,17 @@ import loadImage from "blueimp-load-image"; import { AvatarIconType, IAttachment, + IPageAttachment, } from "@/features/attachments/types/attachment.types.ts"; +import { IPagination, QueryParams } from "@/lib/types.ts"; + +export async function getPageAttachments( + pageId: string, + params?: QueryParams, +): Promise> { + const req = await api.post("/pages/attachments", { pageId, ...params }); + return req.data; +} async function compressAndResizeIcon( file: File, diff --git a/apps/client/src/features/attachments/services/index.ts b/apps/client/src/features/attachments/services/index.ts index 1732ba9fb..07e96c6ba 100644 --- a/apps/client/src/features/attachments/services/index.ts +++ b/apps/client/src/features/attachments/services/index.ts @@ -1,4 +1,5 @@ export { + getPageAttachments, uploadIcon, uploadUserAvatar, uploadSpaceIcon, diff --git a/apps/client/src/features/attachments/types/attachment.types.ts b/apps/client/src/features/attachments/types/attachment.types.ts index 018d8c7c1..ca4517517 100644 --- a/apps/client/src/features/attachments/types/attachment.types.ts +++ b/apps/client/src/features/attachments/types/attachment.types.ts @@ -15,6 +15,15 @@ export interface IAttachment { deletedAt: string | null; } +export interface IPageAttachment extends IAttachment { + url: string; + creator: { + id: string; + name: string; + avatarUrl: string | null; + } | null; +} + export enum AvatarIconType { AVATAR = "avatar", SPACE_ICON = "space-icon", diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index e011e9ec4..9b02a4596 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -11,6 +11,7 @@ import { IconList, IconMarkdown, IconMessage, + IconPaperclip, IconPrinter, IconStar, IconStarFilled, @@ -42,6 +43,7 @@ import { import { formattedDate } from "@/lib/time.ts"; import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; +import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { PageShareModal } from "@/ee/page-permission"; import { @@ -157,6 +159,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { verificationOpened, { open: openVerificationModal, close: closeVerificationModal }, ] = useDisclosure(false); + const [ + attachmentsOpened, + { open: openAttachmentsModal, close: closeAttachmentsModal }, + ] = useDisclosure(false); const [pageEditor] = useAtom(pageEditorAtom); const pageUpdatedAt = useTimeAgo(page?.updatedAt); const favoriteIds = useFavoriteIds("page", page?.spaceId); @@ -293,6 +299,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { )} + {!page?.isBase && ( + } + onClick={openAttachmentsModal} + > + {t("Attachments")} + + )} + {!readOnly && !page?.isBase && ( + + ); } diff --git a/apps/server/src/database/repos/attachment/attachment.repo.ts b/apps/server/src/database/repos/attachment/attachment.repo.ts index d5db1bbf6..abdc36e00 100644 --- a/apps/server/src/database/repos/attachment/attachment.repo.ts +++ b/apps/server/src/database/repos/attachment/attachment.repo.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectKysely } from 'nestjs-kysely'; -import { sql } from 'kysely'; +import { ExpressionBuilder, sql } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { DB } from '@docmost/db/types/db'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { dbOrTx } from '@docmost/db/utils'; import { @@ -96,6 +98,7 @@ export class AttachmentRepo { let query = this.db .selectFrom('attachments') .select(this.baseFields) + .select((eb) => this.withCreator(eb)) .where('pageId', '=', pageId) .where('type', '=', AttachmentType.File) .where('deletedAt', 'is', null); @@ -112,11 +115,20 @@ export class AttachmentRepo { perPage: pagination.limit, cursor: pagination.cursor, beforeCursor: pagination.beforeCursor, - fields: [{ expression: 'id', direction: 'asc' }], + fields: [{ expression: 'id', direction: 'desc' }], parseCursor: (cursor) => ({ id: cursor.id }), }); } + withCreator(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef('users.id', '=', 'attachments.creatorId'), + ).as('creator'); + } + async findByIds( ids: string[], opts?: {