mirror of
https://github.com/docmost/docmost.git
synced 2026-08-24 20:12:13 +10:00
feat: attachments modal
This commit is contained in:
@@ -1297,5 +1297,10 @@
|
|||||||
"Select version from {{date}}": "Select version from {{date}}",
|
"Select version from {{date}}": "Select version from {{date}}",
|
||||||
"Version actions for {{date}}": "Version actions for {{date}}",
|
"Version actions for {{date}}": "Version actions for {{date}}",
|
||||||
"Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}",
|
"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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, { icon: Icon; color: string }> = {
|
||||||
|
".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 (
|
||||||
|
<ThemeIcon variant="light" color={color} size={40} radius="md">
|
||||||
|
<FileIcon size={22} stroke={1.5} />
|
||||||
|
</ThemeIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
opened={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("Attachments")}
|
||||||
|
size={800}
|
||||||
|
closeButtonProps={{ "aria-label": t("Close") }}
|
||||||
|
>
|
||||||
|
<PageAttachmentsList pageId={pageId} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<>
|
||||||
|
<SearchInput
|
||||||
|
onSearch={handleSearch}
|
||||||
|
placeholder={t("Search attachments...")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
) : isError ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("Error loading attachments.")}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
) : attachments.length === 0 ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{search
|
||||||
|
? t("No results found")
|
||||||
|
: t("No attachments on this page yet.")}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
|
||||||
|
{attachments.map((attachment) => (
|
||||||
|
<AttachmentRow key={attachment.id} attachment={attachment} />
|
||||||
|
))}
|
||||||
|
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<Center py="sm">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileUrl = getFileUrl(attachment.url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group wrap="nowrap" gap="md" py="sm" pr="xs">
|
||||||
|
<AttachmentFileIcon
|
||||||
|
fileExt={attachment.fileExt}
|
||||||
|
mimeType={attachment.mimeType}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Anchor
|
||||||
|
href={fileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="sm"
|
||||||
|
fw={500}
|
||||||
|
c="inherit"
|
||||||
|
truncate="end"
|
||||||
|
style={{ display: "block" }}
|
||||||
|
>
|
||||||
|
{attachment.fileName}
|
||||||
|
</Anchor>
|
||||||
|
<Text size="xs" c="dimmed" mt={2} truncate="end">
|
||||||
|
{formatBytes(Number(attachment.fileSize))}
|
||||||
|
{" · "}
|
||||||
|
{formattedDate(new Date(attachment.createdAt))}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attachment.creator && (
|
||||||
|
<Tooltip
|
||||||
|
label={t("Uploaded by {{name}}", { name: attachment.creator.name })}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={attachment.creator.avatarUrl}
|
||||||
|
name={attachment.creator.name}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip label={t("Download attachment")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={fileUrl}
|
||||||
|
download={attachment.fileName}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={t("Download {{name}}", { name: attachment.fileName })}
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<InfiniteData<IPagination<IPageAttachment>, 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,17 @@ import loadImage from "blueimp-load-image";
|
|||||||
import {
|
import {
|
||||||
AvatarIconType,
|
AvatarIconType,
|
||||||
IAttachment,
|
IAttachment,
|
||||||
|
IPageAttachment,
|
||||||
} from "@/features/attachments/types/attachment.types.ts";
|
} from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
|
|
||||||
|
export async function getPageAttachments(
|
||||||
|
pageId: string,
|
||||||
|
params?: QueryParams,
|
||||||
|
): Promise<IPagination<IPageAttachment>> {
|
||||||
|
const req = await api.post("/pages/attachments", { pageId, ...params });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
async function compressAndResizeIcon(
|
async function compressAndResizeIcon(
|
||||||
file: File,
|
file: File,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export {
|
export {
|
||||||
|
getPageAttachments,
|
||||||
uploadIcon,
|
uploadIcon,
|
||||||
uploadUserAvatar,
|
uploadUserAvatar,
|
||||||
uploadSpaceIcon,
|
uploadSpaceIcon,
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ export interface IAttachment {
|
|||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPageAttachment extends IAttachment {
|
||||||
|
url: string;
|
||||||
|
creator: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
export enum AvatarIconType {
|
export enum AvatarIconType {
|
||||||
AVATAR = "avatar",
|
AVATAR = "avatar",
|
||||||
SPACE_ICON = "space-icon",
|
SPACE_ICON = "space-icon",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
|
IconPaperclip,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.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 { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import { PageShareModal } from "@/ee/page-permission";
|
import { PageShareModal } from "@/ee/page-permission";
|
||||||
import {
|
import {
|
||||||
@@ -157,6 +159,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
verificationOpened,
|
verificationOpened,
|
||||||
{ open: openVerificationModal, close: closeVerificationModal },
|
{ open: openVerificationModal, close: closeVerificationModal },
|
||||||
] = useDisclosure(false);
|
] = useDisclosure(false);
|
||||||
|
const [
|
||||||
|
attachmentsOpened,
|
||||||
|
{ open: openAttachmentsModal, close: closeAttachmentsModal },
|
||||||
|
] = useDisclosure(false);
|
||||||
const [pageEditor] = useAtom(pageEditorAtom);
|
const [pageEditor] = useAtom(pageEditorAtom);
|
||||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||||
const favoriteIds = useFavoriteIds("page", page?.spaceId);
|
const favoriteIds = useFavoriteIds("page", page?.spaceId);
|
||||||
@@ -293,6 +299,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!page?.isBase && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconPaperclip size={16} />}
|
||||||
|
onClick={openAttachmentsModal}
|
||||||
|
>
|
||||||
|
{t("Attachments")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
{!readOnly && !page?.isBase && (
|
{!readOnly && !page?.isBase && (
|
||||||
<PageVerificationMenuItem
|
<PageVerificationMenuItem
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
@@ -395,6 +410,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
opened={verificationOpened}
|
opened={verificationOpened}
|
||||||
onClose={closeVerificationModal}
|
onClose={closeVerificationModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PageAttachmentsModal
|
||||||
|
pageId={page.id}
|
||||||
|
open={attachmentsOpened}
|
||||||
|
onClose={closeAttachmentsModal}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
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 { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
import {
|
import {
|
||||||
@@ -96,6 +98,7 @@ export class AttachmentRepo {
|
|||||||
let query = this.db
|
let query = this.db
|
||||||
.selectFrom('attachments')
|
.selectFrom('attachments')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
|
.select((eb) => this.withCreator(eb))
|
||||||
.where('pageId', '=', pageId)
|
.where('pageId', '=', pageId)
|
||||||
.where('type', '=', AttachmentType.File)
|
.where('type', '=', AttachmentType.File)
|
||||||
.where('deletedAt', 'is', null);
|
.where('deletedAt', 'is', null);
|
||||||
@@ -112,11 +115,20 @@ export class AttachmentRepo {
|
|||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
cursor: pagination.cursor,
|
cursor: pagination.cursor,
|
||||||
beforeCursor: pagination.beforeCursor,
|
beforeCursor: pagination.beforeCursor,
|
||||||
fields: [{ expression: 'id', direction: 'asc' }],
|
fields: [{ expression: 'id', direction: 'desc' }],
|
||||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withCreator(eb: ExpressionBuilder<DB, 'attachments'>) {
|
||||||
|
return jsonObjectFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||||
|
.whereRef('users.id', '=', 'attachments.creatorId'),
|
||||||
|
).as('creator');
|
||||||
|
}
|
||||||
|
|
||||||
async findByIds(
|
async findByIds(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
opts?: {
|
opts?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user