mirror of
https://github.com/docmost/docmost
synced 2025-03-28 21:13:28 +00:00
Merge d8d94fd0dd79cc8940a223be0b228597f09a3b40 into 593f41a0502b63bf9e10fbbd8c62cd11a9842096
This commit is contained in:
commit
4e7539d2c2
@ -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/
|
BIN
apps/client/public/default-thumbnail.png
Normal file
BIN
apps/client/public/default-thumbnail.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/client/public/default-upload-file.png
Normal file
BIN
apps/client/public/default-upload-file.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.4 KiB |
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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": "招待されたすべてのメンバーに割り当てるロールを選択してください",
|
||||
|
@ -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": "초대된 모든 사용자에게 할당할 역할 선택",
|
||||
|
@ -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",
|
||||
|
@ -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": "Выберите роль для всех приглашённых участников",
|
||||
|
@ -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": "选择要分配给所有被邀请成员的角色",
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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;;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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" />);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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],
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,10 @@ export class CreatePageDto {
|
||||
@IsString()
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
coverPhoto?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentPageId?: string;
|
||||
|
@ -120,6 +120,7 @@ export class PageService {
|
||||
{
|
||||
title: updatePageDto.title,
|
||||
icon: updatePageDto.icon,
|
||||
coverPhoto: updatePageDto.coverPhoto,
|
||||
lastUpdatedById: userId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
@ -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();
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
4
apps/server/src/database/types/db.d.ts
vendored
4
apps/server/src/database/types/db.d.ts
vendored
@ -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 {
|
||||
|
10
apps/server/src/integrations/images/dto/images-dto.ts
Normal file
10
apps/server/src/integrations/images/dto/images-dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export class ImageDto {
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
altText: string;
|
||||
title: string;
|
||||
attribution: string;
|
||||
attributionUrl: string;
|
||||
}
|
32
apps/server/src/integrations/images/images.controller.ts
Normal file
32
apps/server/src/integrations/images/images.controller.ts
Normal 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, '', '');
|
||||
}
|
||||
}
|
11
apps/server/src/integrations/images/images.module.ts
Normal file
11
apps/server/src/integrations/images/images.module.ts
Normal 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 {}
|
85
apps/server/src/integrations/images/images.service.ts
Normal file
85
apps/server/src/integrations/images/images.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
@ -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
53
pnpm-lock.yaml
generated
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user