Merge d8d94fd0dd79cc8940a223be0b228597f09a3b40 into 593f41a0502b63bf9e10fbbd8c62cd11a9842096

This commit is contained in:
William Cherry 2025-03-22 16:25:40 +00:00 committed by GitHub
commit 4e7539d2c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1247 additions and 30 deletions

View File

@ -41,4 +41,8 @@ SMTP_IGNORETLS=false
POSTMARK_TOKEN=
# for custom drawio server
DRAWIO_URL=
DRAWIO_URL=
# USplash Info
UNSPLASH_ACCESS_KEY=
UNSPLASH_BASE_URL=https://api.unsplash.com/

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -2,6 +2,8 @@
"Account": "Konto",
"Active": "Aktiv",
"Add": "Hinzufügen",
"Add a cover photo": "Ein Titelbild hinzufügen",
"Add Cover Image": "Titelbild hinzufügen",
"Add group members": "Gruppenmitglieder hinzufügen",
"Add groups": "Gruppen hinzufügen",
"Add members": "Mitglieder hinzufügen",
@ -43,6 +45,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.",
"Description": "Beschreibung",
"Details": "Einzelheiten",
"Drag and drop an image to use" : "Ziehen Sie ein Bild hierher",
"e.g ACME": "z.B. ACME",
"e.g ACME Inc": "z.B. ACME Inc.",
"e.g Developers": "z.B. Entwickler",
@ -53,6 +56,7 @@
"e.g Space for product team": "z.B. Bereich für das Produktteam",
"e.g Space for sales team to collaborate": "z.B. Bereich für das Vertriebsteam zur Zusammenarbeit",
"Edit": "Bearbeiten",
"Edit Cover Image": "Titelbild bearbeiten",
"Edit group": "Gruppe bearbeiten",
"Email": "E-Mail",
"Enter a strong password": "Geben Sie ein starkes Passwort ein",
@ -133,6 +137,7 @@
"Profile": "Profil",
"Recently updated": "Kürzlich aktualisiert",
"Remove": "Entfernen",
"Remove Cover Image": "Titelbild entfernen",
"Remove group member": "Gruppenmitglied entfernen",
"Remove space member": "Bereichsmitglied entfernen",
"Restore": "Wiederherstellen",
@ -143,6 +148,8 @@
"Search for users": "Suche nach Benutzern",
"Search for users and groups": "Suche nach Benutzern und Gruppen",
"Search...": "Suche...",
"Search for a cover photo": "Suche nach einem Titelbild",
"Select a cover photo": "Wählen Sie ein Titelbild aus",
"Select language": "Sprache auswählen",
"Select role": "Rolle auswählen",
"Select role to assign to all invited members": "Rolle für alle eingeladenen Mitglieder auswählen",

View File

@ -2,6 +2,8 @@
"Account": "Account",
"Active": "Active",
"Add": "Add",
"Add a cover photo": "Add a cover photo",
"Add Cover Image": "Add Cover Image",
"Add group members": "Add group members",
"Add groups": "Add groups",
"Add members": "Add members",
@ -43,6 +45,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
"Description": "Description",
"Details": "Details",
"Drag and drop an image to use" : "Drag and drop an image to use",
"e.g ACME": "e.g ACME",
"e.g ACME Inc": "e.g ACME Inc",
"e.g Developers": "e.g Developers",
@ -53,6 +56,7 @@
"e.g Space for product team": "e.g Space for product team",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
"Edit": "Edit",
"Edit Cover Image": "Edit Cover Image",
"Edit group": "Edit group",
"Email": "Email",
"Enter a strong password": "Enter a strong password",
@ -133,6 +137,7 @@
"Profile": "Profile",
"Recently updated": "Recently updated",
"Remove": "Remove",
"Remove Cover Image": "Remove Cover Image",
"Remove group member": "Remove group member",
"Remove space member": "Remove space member",
"Restore": "Restore",
@ -143,6 +148,8 @@
"Search for users": "Search for users",
"Search for users and groups": "Search for users and groups",
"Search...": "Search...",
"Search for a cover photo": "Search for a cover photo",
"Select a cover photo": "Select a cover photo",
"Select language": "Select language",
"Select role": "Select role",
"Select role to assign to all invited members": "Select role to assign to all invited members",

View File

@ -2,6 +2,8 @@
"Account": "Cuenta",
"Active": "Activo",
"Add": "Agregar",
"Add a cover photo": "Agregar una foto de portada",
"Add Cover Image": "Agregar imagen de portada",
"Add group members": "Agregar miembros del grupo",
"Add groups": "Agregar grupos",
"Add members": "Agregar miembros",
@ -43,6 +45,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "¿Está seguro de que desea eliminar esta página? Esto eliminará sus dependientes y el historial de la página. Esta acción es irreversible.",
"Description": "Descripción",
"Details": "Detalles",
"Drag and drop an image to use" : "Arrastra y suelta una imagen para usar",
"e.g ACME": "ej: ACME",
"e.g ACME Inc": "ej: ACME Inc",
"e.g Developers": "ej: Desarrolladores",
@ -53,6 +56,7 @@
"e.g Space for product team": "ej: Espacio para el equipo de producto",
"e.g Space for sales team to collaborate": "ej: Espacio para que el equipo de ventas colabore",
"Edit": "Editar",
"Edit Cover Image": "Editar imagen de portada",
"Edit group": "Editar grupo",
"Email": "Correo electrónico",
"Enter a strong password": "Introduce una contraseña fuerte",
@ -133,6 +137,7 @@
"Profile": "Perfil",
"Recently updated": "Recientemente actualizado",
"Remove": "Eliminar",
"Remove Cover Image": "Eliminar imagen de portada",
"Remove group member": "Eliminar miembro del grupo",
"Remove space member": "Eliminar miembro del espacio",
"Restore": "Restaurar",
@ -143,6 +148,8 @@
"Search for users": "Buscar usuarios",
"Search for users and groups": "Buscar usuarios y grupos",
"Search...": "Buscar...",
"Search for a cover photo": "Buscar una foto de portada",
"Select a cover photo": "Seleccionar una foto de portada",
"Select language": "Seleccionar idioma",
"Select role": "Seleccionar rol",
"Select role to assign to all invited members": "Seleccionar rol para asignar a todos los miembros invitados",

View File

@ -2,6 +2,8 @@
"Account": "Compte",
"Active": "Actif",
"Add": "Ajouter",
"Add a cover photo": "Ajouter une photo de couverture",
"Add Cover Image": "Ajouter une image de couverture",
"Add group members": "Ajouter des membres au groupe",
"Add groups": "Ajouter des groupes",
"Add members": "Ajouter des membres",
@ -43,6 +45,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Êtes-vous sûr de vouloir supprimer cette page ? Cela supprimera ses enfants et l'historique de la page. Cette action est irréversible.",
"Description": "Description",
"Details": "Détails",
"Drag and drop an image to use" : "Glissez-déposez une image à utiliser",
"e.g ACME": "par ex. ACME",
"e.g ACME Inc": "par ex. ACME Inc",
"e.g Developers": "par ex. Développeurs",
@ -53,6 +56,7 @@
"e.g Space for product team": "par ex. Espace pour l'équipe produit",
"e.g Space for sales team to collaborate": "par ex. Espace pour l'équipe de vente pour collaborer",
"Edit": "Modifier",
"Edit Cover Image": "Modifier l'image de couverture",
"Edit group": "Modifier groupe",
"Email": "Email",
"Enter a strong password": "Entrez un mot de passe fort",
@ -133,6 +137,7 @@
"Profile": "Profil",
"Recently updated": "Récemment mis à jour",
"Remove": "Retirer",
"Remove Cover Image": "Retirer l'image de couverture",
"Remove group member": "Retirer un membre du groupe",
"Remove space member": "Retirer un membre de l'espace",
"Restore": "Restaurer",
@ -143,6 +148,8 @@
"Search for users": "Rechercher des utilisateurs",
"Search for users and groups": "Rechercher des utilisateurs et des groupes",
"Search...": "Rechercher...",
"Search for a cover photo": "Rechercher une photo de couverture",
"Select a cover photo": "Choisir une photo de couverture",
"Select language": "Sélectionner la langue",
"Select role": "Sélectionner un rôle",
"Select role to assign to all invited members": "Sélectionner le rôle à attribuer à tous les membres invités",

View File

@ -2,6 +2,8 @@
"Account": "Account",
"Active": "Attivo",
"Add": "Aggiungi",
"Add a cover photo": "Aggiungi una foto di copertura",
"Add Cover Image": "Aggiungi immagine di copertura",
"Add group members": "Aggiungi membri al gruppo",
"Add groups": "Aggiungi gruppi",
"Add members": "Aggiungi membri",
@ -43,6 +45,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sei sicuro di voler eliminare questa pagina? Verranno cancellate anche le sue sottopagine e la cronologia. Questa azione è irreversibile.",
"Description": "Descrizione",
"Details": "Dettagli",
"Drag and drop an image to use" : "Trascina e rilascia un'immagine da usare",
"e.g ACME": "es. ACME",
"e.g ACME Inc": "es. ACME Inc",
"e.g Developers": "es. Sviluppatori",
@ -53,6 +56,7 @@
"e.g Space for product team": "es. Spazio per il team di prodotto",
"e.g Space for sales team to collaborate": "es. Spazio per la collaborazione del team di vendita",
"Edit": "Modifica",
"Edit Cover Image": "Modifica immagine di copertura",
"Edit group": "Modifica gruppo",
"Email": "Email",
"Enter a strong password": "Inserisci una password sicura",
@ -133,6 +137,7 @@
"Profile": "Profilo",
"Recently updated": "Aggiornato di recente",
"Remove": "Rimuovi",
"Remove Cover Image": "Rimuovi immagine di copertura",
"Remove group member": "Rimuovi membro dal gruppo",
"Remove space member": "Rimuovi membro dallo spazio",
"Restore": "Ripristina",
@ -144,6 +149,8 @@
"Search for users and groups": "Cerca un utente o un gruppo",
"Search...": "Cerca...",
"Select language": "Seleziona una lingua",
"Search for a cover photo": "Cerca una foto di copertura",
"Select a cover photo": "Seleziona una foto di copertura",
"Select role": "Seleziona un ruolo",
"Select role to assign to all invited members": "Seleziona il ruolo da assegnare a tutti i membri invitati",
"Select theme": "Seleziona un tema",

View File

@ -2,6 +2,8 @@
"Account": "アカウント",
"Active": "アクティブ",
"Add": "追加",
"Add a cover photo": "カバー写真を追加",
"Add Cover Image": "カバー画像を追加",
"Add group members": "グループメンバーを追加",
"Add groups": "グループを追加",
"Add members": "メンバーを追加",
@ -43,6 +45,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?この操作により、子ページおよびページ履歴が削除されます。この操作は元に戻せません。",
"Description": "説明",
"Details": "詳細",
"Drag and drop an image to use" : "画像をドラッグ&ドロップ",
"e.g ACME": "例: 山田太郎",
"e.g ACME Inc": "例: 株式会社サンプル",
"e.g Developers": "例: エンジニア",
@ -53,6 +56,7 @@
"e.g Space for product team": "例: 製品チームのスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
"Edit": "編集",
"Edit Cover Image": "カバー画像を編集",
"Edit group": "グループを編集",
"Email": "メールアドレス",
"Enter a strong password": "強力なパスワードを入力してください",
@ -133,6 +137,7 @@
"Profile": "プロフィール",
"Recently updated": "最近の更新",
"Remove": "削除",
"Remove Cover Image": "カバー画像を削除",
"Remove group member": "グループメンバーを削除",
"Remove space member": "スペースメンバーを削除",
"Restore": "復元",
@ -143,6 +148,8 @@
"Search for users": "ユーザーを検索",
"Search for users and groups": "ユーザーとグループを検索",
"Search...": "検索する語句を入力",
"Search for a cover photo": "カバー写真を検索",
"Select a cover photo": "カバー写真を選択",
"Select language": "言語を選択",
"Select role": "ロールを選択",
"Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください",

View File

@ -2,6 +2,8 @@
"Account": "계정",
"Active": "활성",
"Add": "추가",
"Add a cover photo": "커버 사진 추가",
"Add Cover Image": "커버 이미지 추가",
"Add group members": "팀에 사용자 추가",
"Add groups": "팀 생성",
"Add members": "사용자 추가",
@ -43,6 +45,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "이 페이지를 삭제하시겠습니까? 하위 페이지와 페이지 기록이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
"Description": "설명",
"Details": "세부사항",
"Drag and drop an image to use" : "이미지를 드래그 앤 드롭하여 사용",
"e.g ACME": "예: ACME",
"e.g ACME Inc": "예: ACME Inc",
"e.g Developers": "예: 개발자",
@ -53,6 +56,7 @@
"e.g Space for product team": "예: 제품 팀을 위한 Space",
"e.g Space for sales team to collaborate": "예: 영업 팀의 Space",
"Edit": "편집",
"Edit Cover Image": "커버 이미지 편집",
"Edit group": "팀 편집",
"Email": "이메일",
"Enter a strong password": "강력한 비밀번호를 입력하세요",
@ -133,6 +137,7 @@
"Profile": "프로필",
"Recently updated": "최근 업데이트",
"Remove": "제거",
"Remove Cover Image": "커버 이미지 제거",
"Remove group member": "팀에서 사용자 제거",
"Remove space member": "Space에서 사용자 제거",
"Restore": "복원",
@ -143,6 +148,8 @@
"Search for users": "사용자 검색",
"Search for users and groups": "사용자 및 팀 검색",
"Search...": "검색...",
"Search for a cover photo": "커버 사진 검색",
"Select a cover photo": "커버 사진 선택",
"Select language": "언어 선택",
"Select role": "역할 선택",
"Select role to assign to all invited members": "초대된 모든 사용자에게 할당할 역할 선택",

View File

@ -2,6 +2,8 @@
"Account": "Conta",
"Active": "Ativo",
"Add": "Adicionar",
"Add a cover photo": "Adicionar uma foto de capa",
"Add Cover Image": "Adicionar imagem de capa",
"Add group members": "Adicionar membros ao grupo",
"Add groups": "Adicionar grupos",
"Add members": "Adicionar membros",
@ -43,6 +45,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Você tem certeza que quer deletar essa página? Isso irá deletar todas as páginas filhas e to o histórico. Esta ação é irreversível.",
"Description": "Descrição",
"Details": "Detalhes",
"Drag and drop an image to use" : "Arraste e solte uma imagem para usar",
"e.g ACME": "ex.: ACME",
"e.g ACME Inc": "ex.: ACME Inc",
"e.g Developers": "ex.: Desenvolvedores",
@ -53,6 +56,7 @@
"e.g Space for product team": "ex.: Espaço para a equipe de produto",
"e.g Space for sales team to collaborate": "ex.: Espaço para a equipe de vendas colaborar",
"Edit": "Editar",
"Edit Cover Image": "Editar imagem de capa",
"Edit group": "Editar grupo",
"Email": "Email",
"Enter a strong password": "Insira uma senha forte",
@ -133,6 +137,7 @@
"Profile": "Perfil",
"Recently updated": "Atualizado recentemente",
"Remove": "Remover",
"Remove Cover Image": "Remover imagem de capa",
"Remove group member": "Remover membro do grupo",
"Remove space member": "Remover membro do espaço",
"Restore": "Restaurar",
@ -143,6 +148,8 @@
"Search for users": "Buscar usuários",
"Search for users and groups": "Buscar usuários e grupos",
"Search...": "Buscar...",
"Search for a cover photo": "Buscar uma foto de capa",
"Select a cover photo": "Selecionar uma foto de capa",
"Select language": "Selecionar idioma",
"Select role": "Selecionar função",
"Select role to assign to all invited members": "Selecione a função para atribuir a todos os membros convidados",

View File

@ -2,6 +2,8 @@
"Account": "Аккаунт",
"Active": "Активный",
"Add": "Добавить",
"Add a cover photo": "Добавить обложку",
"Add Cover Image": "Добавить обложку",
"Add group members": "Добавить участников группы",
"Add groups": "Добавить группы",
"Add members": "Добавить участников",
@ -43,6 +45,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
"Description": "Описание",
"Details": "Подробности",
"Drag and drop an image to use" : "Перетащите изображение для использования",
"e.g ACME": "например, ACME",
"e.g ACME Inc": "например, ACME Inc",
"e.g Developers": "например, Разработчики",
@ -53,6 +56,7 @@
"e.g Space for product team": "например, Пространство для продуктовой команды",
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
"Edit": "Редактировать",
"Edit Cover Image": "Редактировать обложку",
"Edit group": "Редактировать группу",
"Email": "Электронная почта",
"Enter a strong password": "Введите надёжный пароль",
@ -133,6 +137,7 @@
"Profile": "Профиль",
"Recently updated": "Обновлено недавно",
"Remove": "Удалить",
"Remove Cover Image": "Удалить обложку",
"Remove group member": "Удалить участника группы",
"Remove space member": "Удалить участника пространства",
"Restore": "Восстановить",
@ -143,6 +148,8 @@
"Search for users": "Поиск пользователей",
"Search for users and groups": "Поиск пользователей и групп",
"Search...": "Поиск...",
"Search for a cover photo": "Поиск обложки",
"Select a cover photo": "Выберите обложку",
"Select language": "Выберите язык",
"Select role": "Выберите роль",
"Select role to assign to all invited members": "Выберите роль для всех приглашённых участников",

View File

@ -2,6 +2,8 @@
"Account": "账户",
"Active": "活跃",
"Add": "添加",
"Add a cover photo": "添加封面照片",
"Add Cover Image": "添加封面图片",
"Add group members": "添加群组成员",
"Add groups": "添加群组",
"Add members": "添加成员",
@ -43,6 +45,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "您确定要删除这个页面吗?这将删除其子页面和页面历史记录。此操作不可逆。",
"Description": "描述",
"Details": "详情",
"Drag and drop an image to use" : "拖放图片使用",
"e.g ACME": "例如ACME",
"e.g ACME Inc": "例如ACME Inc",
"e.g Developers": "例如:开发人员",
@ -53,6 +56,7 @@
"e.g Space for product team": "例如:产品团队的空间",
"e.g Space for sales team to collaborate": "例如:销售团队协作的空间",
"Edit": "编辑",
"Edit Cover Image": "编辑封面图片",
"Edit group": "编辑群组",
"Email": "电子邮箱",
"Enter a strong password": "输入一个强密码",
@ -133,6 +137,7 @@
"Profile": "个人资料",
"Recently updated": "最近更新",
"Remove": "移除",
"Remove Cover Image": "移除封面图片",
"Remove group member": "移除群组成员",
"Remove space member": "移除空间成员",
"Restore": "恢复",
@ -143,6 +148,8 @@
"Search for users": "搜索用户",
"Search for users and groups": "搜索用户和群组",
"Search...": "搜索...",
"Search for a cover photo": "搜索封面照片",
"Select a cover photo": "选择封面照片",
"Select language": "选择语言",
"Select role": "选择角色",
"Select role to assign to all invited members": "选择要分配给所有被邀请成员的角色",

View File

@ -0,0 +1,217 @@
import {
Modal,
Button,
Container,
Group,
Tabs,
Text,
} from "@mantine/core";
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ThumbnailImage } from "./thumbnail-image";
import { searchUnsplashImages, uploadLocalImage, saveImageAsAttachment, searchAttachmentsWithThumbnail } from "./cover-photo.service.ts";
import { IAttachment, IImage } from "@/lib/types.ts";
import classes from "./cover-photo.module.css";
interface CoverPhotoSelectorModalProps {
open: boolean;
pageId: string;
spaceId: string;
onClose: (attachment: IAttachment | null) => void;
}
export default function CoverPhotoSelectorModal({
open,
pageId,
spaceId,
onClose,
}: CoverPhotoSelectorModalProps) {
const [searchTerm, setSearchTerm] = useState<string>("");
const [sourceSystem, setSourceSystem] = useState<string>("unsplash");
const [images, setImages] = useState<IImage[]>([]);
const [droppedFile, setDroppedFile] = useState<File|null>(null);
const [attachments, setAttachments] = useState<IAttachment[]>([]);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const imageRef = useRef<HTMLImageElement>(null);
const { t } = useTranslation();
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
setSourceSystem("unsplash");
setSearchTerm("");
setImages([]);
setAttachments([]);
setDroppedFile(null);
setSelectedIndex(-1);
}, [open]);
useEffect(() => {
setSearchTerm("");
setImages([]);
setAttachments([]);
setDroppedFile(null);
setSelectedIndex(-1);
}, [sourceSystem]);
const handleSearchTermChange = async (query: string) => {
setSearchTerm(query);
clearTimeout(typingTimeoutRef.current!);
typingTimeoutRef.current = setTimeout(async () => {
if (query.length < 1) {
return;
}
console.log("Searching for images with query:", query, sourceSystem);
if (sourceSystem === "unsplash") {
setImages(await searchUnsplashImages(query));
} else if (sourceSystem === "attachments") {
setAttachments(await searchAttachmentsWithThumbnail(query));
}
}, 500);
};
const handleSelected = (index : number) => {
setSelectedIndex(index);
return images[index];
}
const handleSave = async () => {
let attachment: IAttachment | null = null;
if(sourceSystem === "unsplash" && selectedIndex > -1) {
const img = images[selectedIndex];
img.sourceSystem = "unsplash";
attachment = await saveImageAsAttachment(pageId, spaceId, img);
} else if(sourceSystem === "upload" && droppedFile) {
attachment = await uploadLocalImage(pageId, spaceId, droppedFile);
} else if(sourceSystem === "attachments" && selectedIndex > -1) {
attachment = attachments[selectedIndex];
}
onClose(attachment);
setSelectedIndex(-1);
setImages([]);
}
const handleCancel = async () => {
onClose(null);
setSelectedIndex(-1);
setImages([]);
}
return (
<Modal.Root
opened={open}
onClose={handleCancel}
size={500}
padding="xl"
yOffset="24px"
xOffset={0}
mah={400}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{t("Add Cover Photo")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body> {/* Display a tab header, attachments, unsplash, upload */}
<Container size="md">
<Tabs
defaultValue="unsplash"
variant="default"
visibleFrom="sm"
classNames={{
root: classes.tabs,
list: classes.tabsList,
tab: classes.tab,
}}
onChange={(value : string) => {setSourceSystem(value)}}
>
<Tabs.List>
<Tabs.Tab value="unsplash" key="unsplash">Unsplash</Tabs.Tab>
<Tabs.Tab value="attachments" key="attachments">Attachments</Tabs.Tab>
<Tabs.Tab value="upload" key="upload">Upload</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="unsplash">
<Text>{t("Search for a cover photo")}</Text>
<input
type="text"
placeholder={t("Search...")}
onChange={(event) => {handleSearchTermChange(event.target.value)}}
className={classes.searchInput}
value={searchTerm}
/>
<div className={classes.imageGrid}>
{Array.from({ length: 12 }).map((_, index) => (
<div
key={index}
onClick={() => {handleSelected(index)}}
className={classes.imageFrame}>
<ThumbnailImage image={images[index]} selected={selectedIndex === index} />
</div>
))}
</div>
</Tabs.Panel>
<Tabs.Panel value="attachments">
<Text>{t("Search for a cover photo")}</Text>
<input
type="text"
placeholder={t("Search...")}
onChange={(event) => {handleSearchTermChange(event.target.value)}}
className={classes.searchInput}
value={searchTerm}
/>
<div className={classes.imageGrid}>
{Array.from({ length: 12 }).map((_, index) => (
<div
key={index}
onClick={() => {handleSelected(index)}}
className={classes.imageFrame}>
<ThumbnailImage attachment={attachments[index]} selected={selectedIndex === index} />
</div>
))}
</div>
</Tabs.Panel>
<Tabs.Panel value="upload">
<Text>{t("Drag and drop an image to use")}</Text>
<div
style={{
display: "flex",
flexWrap: "wrap",
width: "100%",
height: "400px",
overflowY: "auto",
border: "1px solid #ccc",
borderRadius: "4px",
marginTop: "10px",
alignItems: "center",
justifyContent: "center",
}}
onDrop={(event) => {
event.preventDefault();
const files = event.dataTransfer.files;
if (files.length > 0) {
setDroppedFile(files[0]);
const reader = new FileReader();
reader.onload = (e) => {
imageRef.current.src = e.target?.result as string;
};
reader.readAsDataURL(files[0]);
}
}
}
onDragOver={(event) => event.preventDefault()}>
<img ref={imageRef} src="/default-upload-file.png" title={t("Drag and drop an image to use")}></img>
</div>
</Tabs.Panel>
</Tabs>
</Container>
<Group justify="center" mt="md">
<Button onClick={handleCancel} variant="default">{t("Cancel")}</Button>
<Button onClick={handleSave}>{t("Save")}</Button>
</Group>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}

View File

@ -0,0 +1,144 @@
.container {
margin-top: 28px;
position: relative;
text-align: center;
width: 100%;
height: 220px;
}
.overlay {
position: absolute;
top: 0px;
left: 0;
width: 100%;
height: 220px;
background-color: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(2px);
}
.coverPhoto {
width: 100%;
height: 220px;
object-fit: cover;
}
.empty {
margin-top: 32px;
height: 60px;
padding-bottom: 0;
}
.coverMenu {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(255, 255, 255, 0.75);
padding: 4px 8px;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
}
.coverMenuInverted{
position: absolute;
left: 50%;
top: 112px;
padding: 4px 8px;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.25);
color: white;
}
.coverPhotoAttribution {
position: absolute;
bottom: 8px;
right: 4px;
mix-blend-mode: difference;
color: white;
padding: 2px 4px;
border-radius: 4px;
text-decoration: none;
}
.hidden {
display: none;
}
.selected {
background-color: #f0f0f0;
border: #000 2px solid;
opacity: 0.7;
}
.imageFrame {
background-color: #f0f0f0;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
.imageFrame > img {
max-width: 140px;
max-height: 98px;
object-fit: cover;
}
.imageGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.searchInput {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 8px;
}
.header {
padding-top: var(--mantine-spacing-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), transparent);
margin-bottom: 120px;
}
.mainSection {
padding-bottom: var(--mantine-spacing-sm);
}
.tabsList {
padding-bottom: 16px;
&::before {
display: none;
}
}
.tab {
font-weight: 500;
height: 38px;
background-color: transparent;
position: relative;
bottom: -1px;
&::before,
&::after {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-7)
) !important;
}
&:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
&[data-active] {
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-7));
}
}

View File

@ -0,0 +1,45 @@
import api from "@/lib/api-client";
import { IAttachment, IImage } from "@/lib/types";
export async function searchUnsplashImages(
query: string,
): Promise<IImage[]> {
const req = await api.get<IImage[]>(`/images/search?type=unsplash&query=${query}&orientation=landscape&pageSize=12`);
return req.data;
}
export async function searchAttachmentsWithThumbnail(
query: string,
): Promise<IAttachment[]> {
const req = await api.get<IAttachment[]>(`/attachments/search?query=${query}&pageSize=12`);
return req.data;
}
export async function saveImageAsAttachment(pageId: string, spaceId: string, img: IImage): Promise<IAttachment> {
const body = {
type: "cover-photo",
url: img.url,
description: `Photo by ${img.attribution} on ${img.sourceSystem}`,
descriptionUrl: img.attributionUrl,
pageId,
spaceId,
}
const attachment = await api.post<IAttachment>(`/attachments/upload-remote-image`, body);
return attachment.data;
}
export async function uploadLocalImage(pageId: string, spaceId: string, file: File): Promise<IAttachment> {
const formData = new FormData();
formData.append('type', 'cover-photo');
formData.append('pageId', pageId);
formData.append('spaceId', spaceId);
formData.append('file', file);
const attachment = await api.post<IAttachment>(`/files/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return attachment as unknown as IAttachment;;
}

View File

@ -0,0 +1,82 @@
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import classes from "./cover-photo.module.css";
import CoverPhotoSelectorModal from "./cover-photo-selector.modal";
import React, { useEffect, useRef } from "react";
import { IAttachment } from "@/lib/types";
import { IPage} from "@/features/page/types/page.types";
import { getAttachment, updatePage } from "@/features/page/services/page-service";
import { useTranslation } from "react-i18next";
export interface CoverPhotoProps {
page: IPage;
}
export default function CoverPhoto({page}: CoverPhotoProps) {
const [user] = useAtom(userAtom);
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const [isCoverMenuOpen, setIsCoverMenuOpen] = React.useState(false);
const [currentAttachment, setCurrentAttachment] = React.useState<IAttachment | null>(null);
const coverMenuRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
useEffect(() => {
setCurrentAttachment(null);
if(page?.coverPhoto) {
(async () => {
const attachment = await getAttachment(page.coverPhoto);
setCurrentAttachment(attachment);
})();
}
}, [page]);
const handleAddEditCoverPhoto = async (attachment : IAttachment | null) => {
if(attachment) setCurrentAttachment(attachment);
setIsCoverMenuOpen(false);
}
const removeCurrentAttachment = async () => {
setCurrentAttachment(null);
page.coverPhoto = null;
await updatePage(
{ pageId: page.id,
title: page.title,
parentPageId: page.parentPageId,
icon: page.icon,
coverPhoto: null,
position: page.position
});
}
const containerClass = currentAttachment ? classes.container : classes.empty;
return (
<div className={containerClass}
onMouseEnter={() => {
if (coverMenuRef) {
coverMenuRef.current.classList.remove(classes.hidden);
}
}}
onMouseLeave={() => {
if (coverMenuRef) {
coverMenuRef.current.classList.add(classes.hidden);
}
}}>
<CoverPhotoSelectorModal pageId={page.id} spaceId={page.spaceId} open={isCoverMenuOpen} onClose={(attachment) => {handleAddEditCoverPhoto(attachment)}} />
{currentAttachment ?
<>
<img src={`/api/files/${currentAttachment.id}/${currentAttachment.fileName}`} alt={currentAttachment.description || ""} className={classes.coverPhoto} />
<div ref={coverMenuRef} className={`${classes.overlay} ${classes.hidden}`}>
<div className={classes.coverMenu}>
<span onClick={() => {setIsCoverMenuOpen(true)}}>{t("Edit Cover Image")}</span>
<span> | </span>
<span onClick={removeCurrentAttachment}>{t("Remove Cover Image")}</span>
</div>
{currentAttachment.descriptionUrl ? <a target="_blank" rel="noopener noreferrer" href={currentAttachment.descriptionUrl} className={classes.coverPhotoAttribution}>{currentAttachment.description}</a> :
<span className={classes.coverPhotoAttribution}>{currentAttachment.description}</span>}
</div>
</> :
<div ref={coverMenuRef} className={`${classes.coverMenuInverted} ${classes.hidden}`} onClick={() => {setIsCoverMenuOpen(true)}}>{t("Add Cover Image")}</div>
}
</div>
);
}

View File

@ -0,0 +1,30 @@
import { IAttachment, IImage } from "@/lib/types";
import classes from "./cover-photo.module.css";
/**
*
*
*/
export function ThumbnailImage({image, attachment, selected}: {image?: IImage, attachment?: IAttachment, selected: boolean}) {
let thumbnailUrl, description, title: string;
if (image) {
thumbnailUrl = image.thumbnailUrl;
description = image.altText;
title = `${image.title} by ${image.attribution}`;
} else if (attachment) {
thumbnailUrl = `/api${attachment.thumbnailPath}`;
description = attachment.description || "";
title = attachment.description || "";
}
return (
thumbnailUrl ?
<img
className={selected ? classes.selected : ""}
height={98}
src={thumbnailUrl}
alt={description}
title={title} />
: <img src="/default-thumbnail.png" />);
}

View File

@ -101,3 +101,11 @@ export async function uploadFile(
return req as unknown as IAttachment;
}
export async function getAttachment(
attachmentId: string,
): Promise<IAttachment> {
const req = await api.get<IAttachment>(`/attachments/${attachmentId}`);
return req.data;
}

View File

@ -52,4 +52,20 @@ export interface IAttachment {
createdAt: string;
updatedAt: string;
deletedAt: string | null;
originalPath: string | null;
description: string | null;
descriptionUrl: string | null;
thumbnailPath: string | null;
}
export interface IImage {
url: string;
thumbnailUrl: string;
sourceSystem: string;
width: number;
height: number;
altText: string;
title: string;
attribution: string;
attributionUrl: string;
}

View File

@ -4,6 +4,7 @@ import { FullEditor } from "@/features/editor/full-editor";
import HistoryModal from "@/features/page-history/components/history-modal";
import { Helmet } from "react-helmet-async";
import PageHeader from "@/features/page/components/header/page-header.tsx";
import CoverPhoto from "@/features/page/components/cover-photo/cover-photo.view.tsx";
import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
@ -56,13 +57,15 @@ export default function Page() {
)}
/>
<CoverPhoto page={page} />
<FullEditor
key={page.id}
pageId={page.id}
title={page.title}
content={page.content}
slugId={page.slugId}
spaceSlug={page?.space?.slug}
spaceSlug={page.space?.slug}
editable={spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,

View File

@ -14,6 +14,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from './integrations/health/health.module';
import { ExportModule } from './integrations/export/export.module';
import { ImportModule } from './integrations/import/import.module';
import { ImagesModule } from './integrations/images/images.module';
import { SecurityModule } from './integrations/security/security.module';
const enterpriseModules = [];
@ -41,6 +42,7 @@ try {
StaticModule,
HealthModule,
ImportModule,
ImagesModule,
ExportModule,
StorageModule.forRootAsync({
imports: [EnvironmentModule],

View File

@ -3,10 +3,11 @@ export enum AttachmentType {
WorkspaceLogo = 'workspace-logo',
SpaceLogo = 'space-logo',
File = 'file',
CoverPhoto = 'cover-photo',
}
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
export const MAX_AVATAR_SIZE = '5MB';
export const validImageExtensions = ['.jpg', '.png', '.jpeg', '.webp'];
export const MAX_IMAGE_SIZE = '100MB';
export const inlineFileExtensions = [
'.jpg',

View File

@ -31,7 +31,8 @@ import {getMimeType} from '../../common/helpers';
import {
AttachmentType,
inlineFileExtensions,
MAX_AVATAR_SIZE,
MAX_IMAGE_SIZE,
validImageExtensions,
} from './attachment.constants';
import {
SpaceCaslAction,
@ -78,7 +79,7 @@ export class AttachmentController {
let file = null;
try {
file = await req.file({
limits: {fileSize: maxFileSize, fields: 3, files: 1},
limits: {fileSize: maxFileSize, fields: 99, files: 1},
});
} catch (err: any) {
this.logger.error(err.message);
@ -93,6 +94,7 @@ export class AttachmentController {
throw new BadRequestException('Failed to upload file');
}
const attachmentType = file.fields?.type?.value;
const pageId = file.fields?.pageId?.value;
if (!pageId) {
@ -126,6 +128,7 @@ export class AttachmentController {
pageId: pageId,
spaceId: spaceId,
userId: user.id,
type: attachmentType,
workspaceId: workspace.id,
attachmentId: attachmentId,
});
@ -205,17 +208,17 @@ export class AttachmentController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(MAX_AVATAR_SIZE);
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
let file = null;
try {
file = await req.file({
limits: {fileSize: maxFileSize, fields: 3, files: 1},
limits: {fileSize: maxFileSize, fields: 99, files: 1},
});
} catch (err: any) {
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
`File too large. Exceeds the ${maxFileSize} limit`,
);
}
}
@ -226,6 +229,7 @@ export class AttachmentController {
const attachmentType = file.fields?.type?.value;
const spaceId = file.fields?.spaceId?.value;
const pageId = file.fields?.pageId?.value;
if (!attachmentType) {
throw new BadRequestException('attachment type is required');
@ -270,6 +274,7 @@ export class AttachmentController {
user.id,
workspace.id,
spaceId,
pageId,
);
return res.send(fileResponse);
@ -279,6 +284,70 @@ export class AttachmentController {
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-remote-image')
async uploadRemoteImages(
@Req() req: any,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const {type, url, description, descriptionUrl, spaceId, pageId} = req.body;
if (!type) {
throw new BadRequestException('attachment type is required');
}
if (
!validAttachmentTypes.includes(type) ||
type === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
if (type === AttachmentType.WorkspaceLogo) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
}
if (type === AttachmentType.SpaceLogo) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
try {
const attachment = await this.attachmentService.uploadRemoteImage(
url,
type,
user.id,
workspace.id,
spaceId,
pageId,
description,
descriptionUrl,
);
return attachment;
} catch (err: any) {
this.logger.error(err);
throw new BadRequestException('Error processing stream upload.');
}
}
@Get('attachments/img/:attachmentType/:fileName')
async getLogoOrAvatar(
@Res() res: FastifyReply,
@ -307,4 +376,39 @@ export class AttachmentController {
throw new NotFoundException('File not found');
}
}
@UseGuards(JwtAuthGuard)
@Get('attachments/:attachmentId')
async getAttachment(@Param('attachmentId') attachmentId: string) {
if (!isValidUUID(attachmentId)) {
throw new NotFoundException('Invalid file id');
}
const attachment = await this.attachmentRepo.findById(attachmentId);
if (!attachment) {
throw new NotFoundException();
}
return attachment;
}
@UseGuards(JwtAuthGuard)
@Get('attachments/search')
async searchAttachments(
@AuthUser() user: User,
@Req() req: any,
) {
const {query, pageSize, page} = req.query;
const limit = pageSize ? parseInt(pageSize) : 10;
const offset = page ? (parseInt(page) - 1) * limit : 0;
const attachments = await this.attachmentService.searchAttachments(
user.workspaceId,
query,
validImageExtensions,
limit,
offset);
return attachments;
}
}

View File

@ -22,6 +22,10 @@ import { executeTx } from '@docmost/db/utils';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import axios from "axios";
import {fileTypeFromBuffer} from 'file-type';
@Injectable()
export class AttachmentService {
@ -32,6 +36,7 @@ export class AttachmentService {
private readonly userRepo: UserRepo,
private readonly workspaceRepo: WorkspaceRepo,
private readonly spaceRepo: SpaceRepo,
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@ -42,8 +47,9 @@ export class AttachmentService {
spaceId: string;
workspaceId: string;
attachmentId?: string;
type?: AttachmentType;
}) {
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
const { filePromise, pageId, spaceId, userId, workspaceId, type } = opts;
const preparedFile: PreparedFile = await prepareFile(filePromise);
let isUpdate = false;
@ -80,25 +86,32 @@ export class AttachmentService {
let attachment: Attachment = null;
try {
if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment(
{
updatedAt: new Date(),
},
attachmentId,
);
} else {
attachment = await this.saveAttachment({
attachmentId,
preparedFile,
filePath,
type: AttachmentType.File,
userId,
spaceId,
workspaceId,
pageId,
});
}
await executeTx(this.db, async (trx) => {
if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment(
{
updatedAt: new Date(),
},
attachmentId,
);
} else {
attachment = await this.saveAttachment({
attachmentId,
preparedFile,
filePath,
type: type || AttachmentType.File,
userId,
spaceId,
workspaceId,
pageId,
});
}
if (type === AttachmentType.CoverPhoto) {
const page = await this.pageRepo.findById(pageId, {trx});
await this.pageRepo.updatePage({...page, coverPhoto: attachment.id}, pageId, trx);
}
});
} catch (err) {
// delete uploaded file on error
this.logger.error(err);
@ -112,10 +125,12 @@ export class AttachmentService {
type:
| AttachmentType.Avatar
| AttachmentType.WorkspaceLogo
| AttachmentType.CoverPhoto
| AttachmentType.SpaceLogo,
userId: string,
workspaceId: string,
spaceId?: string,
pageId?: string,
) {
const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions);
@ -137,6 +152,8 @@ export class AttachmentService {
type,
userId,
workspaceId,
pageId,
spaceId,
trx,
});
@ -153,6 +170,9 @@ export class AttachmentService {
workspaceId,
trx,
);
} else if (type === AttachmentType.CoverPhoto) {
const page = await this.pageRepo.findById(pageId, {trx});
await this.pageRepo.updatePage({...page, coverPhoto: attachment.id}, pageId, trx);
} else if (type === AttachmentType.WorkspaceLogo) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
trx,
@ -199,6 +219,120 @@ export class AttachmentService {
return attachment;
}
async uploadRemoteImage(
url: string,
type:
| AttachmentType.Avatar
| AttachmentType.WorkspaceLogo
| AttachmentType.CoverPhoto
| AttachmentType.SpaceLogo,
userId: string,
workspaceId: string,
spaceId?: string,
pageId?: string,
description?: string,
descriptionUrl?: string,
) {
const buffer = await this.loadImage(url);
const { mime, ext } = await fileTypeFromBuffer(buffer);
if (!mime || !ext) {
throw new BadRequestException('Invalid file type');
}
const mimeType = mime;
const fileExtension = `.${ext}`;
const fileName = uuid4() + fileExtension;
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${fileName}`;
console.log("filePath", filePath, "size", buffer.length);
await this.uploadToDrive(filePath, buffer);
let attachment: Attachment = null;
let oldFileName: string = null;
try {
await executeTx(this.db, async (trx) => {
attachment = await this.saveRemoteAttachment({
filePath,
fileName,
fileSize: buffer.length,
mimeType,
fileExtension,
type,
userId,
workspaceId,
spaceId,
pageId,
orginalPath: url,
description,
descriptionUrl,
trx,
});
if (type === AttachmentType.Avatar) {
const user = await this.userRepo.findById(userId, workspaceId, {
trx,
});
oldFileName = user.avatarUrl;
await this.userRepo.updateUser(
{ avatarUrl: fileName },
userId,
workspaceId,
trx,
);
} else if (type === AttachmentType.CoverPhoto) {
const page = await this.pageRepo.findById(pageId, {trx});
await this.pageRepo.updatePage({...page, coverPhoto: attachment.id}, page.id, trx);
} else if (type === AttachmentType.WorkspaceLogo) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
trx,
});
oldFileName = workspace.logo;
await this.workspaceRepo.updateWorkspace(
{ logo: fileName },
workspaceId,
trx,
);
} else if (type === AttachmentType.SpaceLogo && spaceId) {
const space = await this.spaceRepo.findById(spaceId, workspaceId, {
trx,
});
oldFileName = space.logo;
await this.spaceRepo.updateSpace(
{ logo: fileName },
spaceId,
workspaceId,
trx,
);
} else {
throw new BadRequestException(`Image upload aborted.`);
}
});
} catch (err) {
// delete uploaded file on db update failure
this.logger.error('Image upload error:', err);
await this.deleteRedundantFile(filePath);
throw new BadRequestException('Failed to upload image');
}
if (oldFileName && !oldFileName.toLowerCase().startsWith('http')) {
// delete old avatar or logo
const oldFilePath =
getAttachmentFolderPath(type, workspaceId) + '/' + oldFileName;
await this.deleteRedundantFile(oldFilePath);
}
return attachment;
}
async deleteRedundantFile(filePath: string) {
try {
await this.storageService.delete(filePath);
@ -217,6 +351,16 @@ export class AttachmentService {
}
}
async loadImage(imageUrl: string): Promise<Buffer<ArrayBuffer>> {
try {
const response = await axios.get(imageUrl, { responseType: 'arraybuffer' });
return Buffer.from(response.data, 'binary');
} catch (error) {
this.logger.error(`Failed to load and save image: ${error}`);
throw new BadRequestException('Failed to load and save image');
}
}
async saveAttachment(opts: {
attachmentId?: string;
preparedFile: PreparedFile;
@ -226,6 +370,9 @@ export class AttachmentService {
workspaceId: string;
pageId?: string;
spaceId?: string;
orginalPath?: string;
description?: string;
descriptionUrl?: string;
trx?: KyselyTransaction;
}): Promise<Attachment> {
const {
@ -237,6 +384,9 @@ export class AttachmentService {
workspaceId,
pageId,
spaceId,
orginalPath,
description,
descriptionUrl,
trx,
} = opts;
return this.attachmentRepo.insertAttachment(
@ -252,6 +402,64 @@ export class AttachmentService {
workspaceId: workspaceId,
pageId: pageId,
spaceId: spaceId,
orginalPath,
description,
descriptionUrl: descriptionUrl,
},
trx,
);
}
async saveRemoteAttachment(opts: {
attachmentId?: string;
filePath: string;
fileName: string;
fileSize: number;
mimeType: string;
fileExtension: string;
type: AttachmentType;
userId: string;
workspaceId: string;
pageId?: string;
spaceId?: string;
orginalPath?: string;
description?: string;
descriptionUrl?: string;
trx?: KyselyTransaction;
}): Promise<Attachment> {
const {
attachmentId,
filePath,
fileName,
fileSize,
mimeType,
fileExtension,
type,
userId,
workspaceId,
pageId,
spaceId,
orginalPath,
description,
descriptionUrl,
trx,
} = opts;
return this.attachmentRepo.insertAttachment(
{
id: attachmentId,
type,
filePath,
fileName,
fileSize,
mimeType,
fileExt: fileExtension,
creatorId: userId,
workspaceId: workspaceId,
pageId: pageId,
spaceId: spaceId,
orginalPath,
description,
descriptionUrl,
},
trx,
);
@ -289,4 +497,30 @@ export class AttachmentService {
throw err;
}
}
async findAndAttachThumbnails(attachments: Attachment[]) {
const thumbnailPromises = attachments.map(async (attachment) => {
if (attachment.fileExt === '.svg') {
attachment.thumbnailPath = attachment.filePath;
return;
}
if (attachment.thumbnailPath) {
return;
}
const thumbnailPath = `/files/${attachment.id}/${attachment.fileName}`;
// TODO: implement thumbnail generation code
// await this.storageService.findOrCreateThumbnail(attachment.filePath);
if (thumbnailPath) {
attachment.thumbnailPath = thumbnailPath;
}
return;
});
await Promise.all(thumbnailPromises);
}
async searchAttachments(workspaceId: string, query: string, fileExts: string[], limit: number, offset: number) {
const attachments = await this.attachmentRepo.search(query, {workspaceId, limit, offset, fileExts});
this.findAndAttachThumbnails(attachments);
return attachments;
}
}

View File

@ -9,6 +9,10 @@ export class CreatePageDto {
@IsString()
icon?: string;
@IsOptional()
@IsString()
coverPhoto?: string;
@IsOptional()
@IsString()
parentPageId?: string;

View File

@ -120,6 +120,7 @@ export class PageService {
{
title: updatePageDto.title,
icon: updatePageDto.icon,
coverPhoto: updatePageDto.coverPhoto,
lastUpdatedById: userId,
updatedAt: new Date(),
},

View File

@ -0,0 +1,19 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('attachments')
.addColumn('orginal_path', 'varchar', (col) => col)
.addColumn('description', 'varchar', (col) => col)
.addColumn('description_url', 'varchar', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('attachments')
.dropColumn('orginal_path')
.dropColumn('description')
.dropColumn('description_url')
.execute();
}

View File

@ -80,4 +80,41 @@ export class AttachmentRepo {
.where('filePath', '=', attachmentFilePath)
.executeTakeFirst();
}
async search(
query: string,
opts?: {
spaceId?: string,
workspaceId?: string,
fileExts?: string[],
limit?: number,
offset?: number,
trx?: KyselyTransaction;
},
): Promise<Attachment[]> {
const db = dbOrTx(this.db, opts?.trx);
let statement = db
.selectFrom('attachments')
.selectAll()
.where((eb) =>
eb('fileName', 'ilike', `%${query}%`).or(
'description', 'ilike', `%${query}%`)
);
if(opts?.spaceId) {
statement = statement.where('spaceId', '=', opts.spaceId);
}
if(opts?.workspaceId) {
statement = statement.where('workspaceId', '=', opts.workspaceId);
}
if(opts?.fileExts) {
statement = statement.where('fileExt', 'in', opts.fileExts);
}
statement = statement
.limit(opts?.limit || 100)
.offset(opts?.offset || 0)
.orderBy('createdAt', 'desc');
return statement.execute();
}
}

View File

@ -42,6 +42,10 @@ export interface Attachments {
type: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
orginalPath: string | null;
description: string | null;
descriptionUrl: string | null;
thumbnailPath: string | null;
}
export interface AuthAccounts {

View File

@ -0,0 +1,10 @@
export class ImageDto {
url: string;
thumbnailUrl: string;
width: number;
height: number;
altText: string;
title: string;
attribution: string;
attributionUrl: string;
}

View File

@ -0,0 +1,32 @@
import {
Controller,
HttpCode,
HttpStatus,
Logger,
Get,
UseGuards,
Query,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ImagesService } from './images.service';
@Controller()
export class ImagesController {
private readonly logger = new Logger(ImagesController.name);
constructor(
private readonly imagesService: ImagesService,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Get('/images/search')
async imagesSearch(@Query() query: { type?: string, page?: number; pageSize?: number; query?: string, orientation?: string }) {
const searchTerm = query.query || '';
const orientation = query.orientation || 'any';
const type = query.type || 'unsplash';
const pageSize = query.pageSize ? query.pageSize : 10;
const page = query.page ? (query.page - 1) * pageSize : 0;
return this.imagesService.search(searchTerm, orientation, type, page, pageSize, '', '');
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ImagesService } from './images.service';
import { ImagesController } from './images.controller';
@Module({
providers: [ImagesService],
controllers: [ImagesController],
exports: [ImagesService],
imports: [],
})
export class ImagesModule {}

View File

@ -0,0 +1,85 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ImageDto } from './dto/images-dto';
import axios, { AxiosInstance } from "axios";
/**
* Service for handling image-related operations.
*/
@Injectable()
export class ImagesService {
private readonly logger = new Logger(ImagesService.name);
private readonly unsplash: AxiosInstance = axios.create({
baseURL: process.env.UNSPLASH_BASE_URL,
timeout: 10000,
});
async search(
searchTerm: string,
orientation: string,
type: string,
page: number,
pageSize: number,
spaceId: string,
workspaceId: string,
): Promise<Array<ImageDto>> {
if (type === 'attachment') {
// Handle attachment search logic here
throw new BadRequestException('Attachment search not implemented');
} else if (type === 'unsplash') {
// Handle Unsplash search logic here
return await this.searchUnsplash(searchTerm, orientation, page, pageSize);
}
return [];
}
async searchUnsplash(searchTerm: string, orientation: string, page: number, pageSize: number): Promise<Array<ImageDto>> {
const images = Array<ImageDto>();
try {
const response = await this.unsplash.get("search/photos", {
params: {
query: searchTerm,
orientation,
page,
per_page: pageSize,
},
headers: {
'Accept-Version': 'v1',
'Authorization': `Client-ID ${process.env.UNSPLASH_ACCESS_KEY}`,
}
});
const results = response.data.results;
for (const result of results) {
const image = new ImageDto();
image.url = result.urls.regular;
image.attribution = result.user.name;
image.altText = result.alt_description || "untitled";
image.thumbnailUrl = result.urls.thumb;
image.width = result.width;
image.height = result.height;
image.title = result.description || result.alt_description || "untitled";
image.attributionUrl = result.user.links.html;
images.push(image);
}
} catch (err) {
const message = 'Error processing file content';
throw new BadRequestException(message);
}
return images;
}
async loadImage(imageUrl: string): Promise<Buffer<ArrayBuffer>> {
try {
const response = await this.unsplash.get(imageUrl, { responseType: 'arraybuffer' });
const fileBuffer = Buffer.from(response.data, 'binary');
return fileBuffer;
} catch (error) {
this.logger.error(`Failed to load and save image: ${error}`);
throw new BadRequestException('Failed to load and save image');
}
}
}

View File

@ -71,7 +71,8 @@
"marked": "^13.0.3",
"uuid": "^11.1.0",
"y-indexeddb": "^9.0.12",
"yjs": "^13.6.20"
"yjs": "^13.6.20",
"file-type": "^20.4.0"
},
"devDependencies": {
"@nx/js": "20.4.5",

53
pnpm-lock.yaml generated
View File

@ -154,6 +154,9 @@ importers:
dompurify:
specifier: ^3.2.4
version: 3.2.4
file-type:
specifier: ^20.4.0
version: 20.4.0
fractional-indexing-jittered:
specifier: ^1.0.0
version: 1.0.0
@ -3903,6 +3906,13 @@ packages:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tokenizer/inflate@0.2.7':
resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==}
engines: {node: '>=18'}
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
'@tsconfig/node10@1.0.9':
resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==}
@ -5747,6 +5757,9 @@ packages:
picomatch:
optional: true
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'}
@ -5758,6 +5771,10 @@ packages:
file-saver@2.0.5:
resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
file-type@20.4.0:
resolution: {integrity: sha512-+NZeExsi4G6EWaMbSmvBeCoqsj9EqNvOj1o/0uPVPW4O51FSCmxFlNEp/PitsqBMCbax4cGoaYmnUK5FLTuG4g==}
engines: {node: '>=18'}
filelist@1.0.4:
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
@ -7334,6 +7351,10 @@ packages:
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
peek-readable@6.1.1:
resolution: {integrity: sha512-7QmvgRKhxM0E2PGV4ocfROItVode+ELI27n4q+lpufZ+tRKBu/pBP8WOmw9HXn2ui/AUizqtvaVQhcJrOkRqYg==}
engines: {node: '>=18'}
pg-cloudflare@1.1.1:
resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==}
@ -8260,6 +8281,10 @@ packages:
strnum@1.0.5:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
strtok3@10.2.1:
resolution: {integrity: sha512-Q2dTnW3UXokAvXmXvrvMoUj/me3LyJI76HNHeuGMh2o0As/vzd7eHV3ncLOyvu928vQIDbE7Vf9ldEnC7cwy1w==}
engines: {node: '>=18'}
styled-jsx@5.1.1:
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
engines: {node: '>= 12.0.0'}
@ -8403,6 +8428,10 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
token-types@6.0.0:
resolution: {integrity: sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==}
engines: {node: '>=14.16'}
tough-cookie@5.1.0:
resolution: {integrity: sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg==}
engines: {node: '>=16'}
@ -8601,6 +8630,10 @@ packages:
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
engines: {node: '>=8'}
uint8array-extras@1.4.0:
resolution: {integrity: sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==}
engines: {node: '>=18'}
unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
@ -15399,6 +15432,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.2
fflate@0.8.2: {}
figures@3.2.0:
dependencies:
escape-string-regexp: 1.0.5
@ -15409,6 +15444,15 @@ snapshots:
file-saver@2.0.5: {}
file-type@20.4.0:
dependencies:
'@tokenizer/inflate': 0.2.7
strtok3: 10.2.1
token-types: 6.0.0
uint8array-extras: 1.4.0
transitivePeerDependencies:
- supports-color
filelist@1.0.4:
dependencies:
minimatch: 5.1.6
@ -17238,6 +17282,8 @@ snapshots:
peberminta@0.9.0: {}
peek-readable@6.1.1: {}
pg-cloudflare@1.1.1:
optional: true
@ -18276,6 +18322,11 @@ snapshots:
strnum@1.0.5: {}
strtok3@10.2.1:
dependencies:
'@tokenizer/token': 0.3.0
peek-readable: 6.1.1
styled-jsx@5.1.1(@babel/core@7.24.5)(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies:
client-only: 0.0.1
@ -18622,6 +18673,8 @@ snapshots:
dependencies:
'@lukeed/csprng': 1.1.0
uint8array-extras@1.4.0: {}
unbox-primitive@1.0.2:
dependencies:
call-bind: 1.0.7