mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ feat(resource): add select all hint and improve resource explorer selection (#13134)
* ✨ feat(resource): add select all hint and improve resource explorer selection Made-with: Cursor * ♻️ refactor(resource): flatten store actions and improve type imports Made-with: Cursor * ♻️ refactor resource explorer list view * refactor: engine Signed-off-by: Innei <tukon479@gmail.com> * ✨ feat: checkpoint current workspace updates * ♻️ refine resource explorer fetch ownership * 🐛 fix: resolve resource manager ci regressions * 🐛 fix(lambda): delete page-backed knowledge items by document id * 🐛 fix(lambda): include knowledge-base files in remove-all * 🐛 fix(resource): preserve cross-page select-all exclusions * 🐛 fix(resource): retain off-screen optimistic resources * 🐛 fix(resource): hide moved root items from current query * 🐛 fix(resource): reset explorer selection on query change * 🐛 fix(resource): fix select-all batchChunking and optimistic replace visibility - batchChunking: pass through server-resolved IDs not in local resourceMap when selectAllState is 'all', letting server filter unsupported types - replaceLocalResource: keep replacement visible if the optimistic item was already in the list, avoiding slug-vs-UUID mismatch in visibility check * 🐛 fix(resource): reset selectAllState after batch operations and preserve off-screen optimistic items - Reset selectAllState to 'none' after delete, removeFromKnowledgeBase, and batchChunking to prevent stale 'all' state causing unintended re-selection of remaining items - Preserve off-screen optimistic resources in clearCurrentQueryResources so background uploads from other folders survive delete-all-by-query * 🐛 fix: satisfy import-x/first in resource action test Made-with: Cursor * 🎨 lint: sort imports in ResourceExplorer Made-with: Cursor * 🐛 fix: widen searchQuery type in useResetSelectionOnQueryChange test Made-with: Cursor --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
f4c4ba7db5
commit
26449e522a
133 changed files with 6089 additions and 3101 deletions
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "تقسيم الملف إلى أجزاء نصية متعددة وتضمينها للبحث الدلالي والحوار مع الملف.",
|
||||
"FileManager.actions.chunkingUnsupported": "هذا الملف لا يدعم التجزئة.",
|
||||
"FileManager.actions.confirmDelete": "أنت على وشك حذف هذا الملف. لا يمكن استعادته بعد الحذف. يرجى تأكيد الإجراء.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "أنت على وشك حذف جميع النتائج في العرض الحالي. بمجرد حذفها، لا يمكن استعادتها. يرجى تأكيد الإجراء.",
|
||||
"FileManager.actions.confirmDeleteFolder": "أنت على وشك حذف هذا المجلد وجميع محتوياته. لا يمكن التراجع عن هذا الإجراء. يرجى تأكيد القرار.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "أنت على وشك حذف {{count}} ملفًا محددًا. لا يمكن استعادتها بعد الحذف. يرجى تأكيد الإجراء.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "أنت على وشك إزالة {{count}} ملف/ملفات محددة من المكتبة. ستظل متاحة في جميع الملفات. أكد للمتابعة.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "تاريخ الإنشاء",
|
||||
"FileManager.title.size": "الحجم",
|
||||
"FileManager.title.title": "الملف",
|
||||
"FileManager.total.allSelectedCount": "تم تحديد جميع العناصر البالغ عددها {{count}}.",
|
||||
"FileManager.total.allSelectedFallback": "تم تحديد جميع النتائج.",
|
||||
"FileManager.total.fileCount": "الإجمالي {{count}} عنصر",
|
||||
"FileManager.total.loadedSelectedCount": "تم تحديد {{count}} من العناصر المحملة.",
|
||||
"FileManager.total.selectAll": "تحديد جميع العناصر البالغ عددها {{count}}",
|
||||
"FileManager.total.selectAllFallback": "تحديد جميع العناصر",
|
||||
"FileManager.total.selectedCount": "المحدد {{count}} عنصر",
|
||||
"FileManager.view.list": "عرض القائمة",
|
||||
"FileManager.view.masonry": "عرض الشبكة",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "الموارد",
|
||||
"toggleLeftPanel": "إظهار/إخفاء اللوحة الجانبية",
|
||||
"uploadDock.body.collapse": "طي",
|
||||
"uploadDock.header.cancelAll": "إلغاء الكل",
|
||||
"uploadDock.body.item.cancel": "إلغاء",
|
||||
"uploadDock.body.item.cancelled": "تم الإلغاء",
|
||||
"uploadDock.body.item.done": "تم التحميل",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Разделя файла на няколко текстови части и ги вгражда за семантично търсене и диалог с файла.",
|
||||
"FileManager.actions.chunkingUnsupported": "Този файл не поддържа разделяне.",
|
||||
"FileManager.actions.confirmDelete": "Ще изтриете този файл. След изтриване не може да бъде възстановен. Моля, потвърдете действието си.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "Предстои да изтриете всички резултати в текущия изглед. След изтриване те не могат да бъдат възстановени. Моля, потвърдете действието.",
|
||||
"FileManager.actions.confirmDeleteFolder": "Ще изтриете тази папка и цялото ѝ съдържание. Това действие не може да бъде отменено. Моля, потвърдете решението си.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "Ще изтриете избраните {{count}} файла. След изтриване те не могат да бъдат възстановени. Моля, потвърдете действието си.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "Ще премахнете {{count}} избран(и) файл(а) от библиотеката. Те ще останат достъпни във Всички файлове. Потвърдете, за да продължите.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Създаден на",
|
||||
"FileManager.title.size": "Размер",
|
||||
"FileManager.title.title": "Файл",
|
||||
"FileManager.total.allSelectedCount": "Всички {{count}} елемента са избрани.",
|
||||
"FileManager.total.allSelectedFallback": "Всички резултати са избрани.",
|
||||
"FileManager.total.fileCount": "Общо {{count}} елемента",
|
||||
"FileManager.total.loadedSelectedCount": "Избрани са {{count}} заредени елемента.",
|
||||
"FileManager.total.selectAll": "Избери всички {{count}} елемента",
|
||||
"FileManager.total.selectAllFallback": "Избери всички елементи",
|
||||
"FileManager.total.selectedCount": "Избрани {{count}} елемента",
|
||||
"FileManager.view.list": "Изглед списък",
|
||||
"FileManager.view.masonry": "Изглед мрежа",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "Ресурси",
|
||||
"toggleLeftPanel": "Показване/Скриване на ляв панел",
|
||||
"uploadDock.body.collapse": "Свий",
|
||||
"uploadDock.header.cancelAll": "Отказ на всички",
|
||||
"uploadDock.body.item.cancel": "Отказ",
|
||||
"uploadDock.body.item.cancelled": "Отказано",
|
||||
"uploadDock.body.item.done": "Качено",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Datei in mehrere Textsegmente aufteilen und einbetten für semantische Suche und Dateidialog.",
|
||||
"FileManager.actions.chunkingUnsupported": "Diese Datei unterstützt keine Segmentierung.",
|
||||
"FileManager.actions.confirmDelete": "Sie sind dabei, diese Datei zu löschen. Nach dem Löschen kann sie nicht wiederhergestellt werden. Bitte bestätigen Sie Ihre Aktion.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "Sie sind dabei, alle Ergebnisse in der aktuellen Ansicht zu löschen. Nach dem Löschen können sie nicht wiederhergestellt werden. Bitte bestätigen Sie Ihre Aktion.",
|
||||
"FileManager.actions.confirmDeleteFolder": "Sie sind dabei, diesen Ordner und seinen gesamten Inhalt zu löschen. Diese Aktion kann nicht rückgängig gemacht werden. Bitte bestätigen Sie Ihre Entscheidung.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "Sie sind dabei, die ausgewählten {{count}} Dateien zu löschen. Nach dem Löschen können sie nicht wiederhergestellt werden. Bitte bestätigen Sie Ihre Aktion.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "Sie sind dabei, {{count}} ausgewählte Datei(en) aus der Bibliothek zu entfernen. Sie bleiben weiterhin unter 'Alle Dateien' verfügbar. Bitte bestätigen Sie, um fortzufahren.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Erstellt am",
|
||||
"FileManager.title.size": "Größe",
|
||||
"FileManager.title.title": "Datei",
|
||||
"FileManager.total.allSelectedCount": "Alle {{count}} Elemente sind ausgewählt.",
|
||||
"FileManager.total.allSelectedFallback": "Alle Ergebnisse sind ausgewählt.",
|
||||
"FileManager.total.fileCount": "Insgesamt {{count}} Elemente",
|
||||
"FileManager.total.loadedSelectedCount": "{{count}} geladene Elemente ausgewählt.",
|
||||
"FileManager.total.selectAll": "Alle {{count}} Elemente auswählen",
|
||||
"FileManager.total.selectAllFallback": "Alle Elemente auswählen",
|
||||
"FileManager.total.selectedCount": "{{count}} Elemente ausgewählt",
|
||||
"FileManager.view.list": "Listenansicht",
|
||||
"FileManager.view.masonry": "Rasteransicht",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "Ressourcen",
|
||||
"toggleLeftPanel": "Linkes Panel ein-/ausblenden",
|
||||
"uploadDock.body.collapse": "Einklappen",
|
||||
"uploadDock.header.cancelAll": "Alle abbrechen",
|
||||
"uploadDock.body.item.cancel": "Abbrechen",
|
||||
"uploadDock.body.item.cancelled": "Abgebrochen",
|
||||
"uploadDock.body.item.done": "Hochgeladen",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Split the file into multiple text chunks and embedding them for semantic search and file dialogue.",
|
||||
"FileManager.actions.chunkingUnsupported": "This file does not support chunking.",
|
||||
"FileManager.actions.confirmDelete": "You are about to delete this file. Once deleted, it cannot be recovered. Please confirm your action.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "You are about to delete all results in the current view. Once deleted, they cannot be recovered. Please confirm your action.",
|
||||
"FileManager.actions.confirmDeleteFolder": "You are about to delete this folder and all of its contents. This action cannot be undone. Please confirm your decision.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "You are about to delete the selected {{count}} files. Once deleted, they cannot be recovered. Please confirm your action.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "You're about to remove {{count}} selected file(s) from the Library. They'll still be available in All Files. Confirm to continue.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Created At",
|
||||
"FileManager.title.size": "Size",
|
||||
"FileManager.title.title": "File",
|
||||
"FileManager.total.allSelectedCount": "All {{count}} items are selected.",
|
||||
"FileManager.total.allSelectedFallback": "All results are selected.",
|
||||
"FileManager.total.fileCount": "Total {{count}} items",
|
||||
"FileManager.total.loadedSelectedCount": "Selected {{count}} loaded items.",
|
||||
"FileManager.total.selectAll": "Select all {{count}} items",
|
||||
"FileManager.total.selectAllFallback": "Select all items",
|
||||
"FileManager.total.selectedCount": "Selected {{count}} items",
|
||||
"FileManager.view.list": "List View",
|
||||
"FileManager.view.masonry": "Grid View",
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@
|
|||
"uploadDock.body.item.processing": "Processing file...",
|
||||
"uploadDock.body.item.restTime": "Remaining {{time}}",
|
||||
"uploadDock.fileQueueInfo": "Uploading the first {{count}} files, {{remaining}} remaining in queue",
|
||||
"uploadDock.header.cancelAll": "Cancel all",
|
||||
"uploadDock.totalCount": "Total {{count}} items",
|
||||
"uploadDock.uploadStatus.cancelled": "Upload cancelled",
|
||||
"uploadDock.uploadStatus.error": "Upload error",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Divide el archivo en múltiples fragmentos de texto y los incrusta para búsqueda semántica y diálogo de archivos.",
|
||||
"FileManager.actions.chunkingUnsupported": "Este archivo no admite fragmentación.",
|
||||
"FileManager.actions.confirmDelete": "Estás a punto de eliminar este archivo. Una vez eliminado, no se podrá recuperar. Por favor, confirma tu acción.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "Está a punto de eliminar todos los resultados de la vista actual. Una vez eliminados, no se podrán recuperar. Confirme la acción.",
|
||||
"FileManager.actions.confirmDeleteFolder": "Estás a punto de eliminar esta carpeta y todo su contenido. Esta acción no se puede deshacer. Por favor, confirma tu decisión.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "Estás a punto de eliminar los {{count}} archivos seleccionados. Una vez eliminados, no se podrán recuperar. Por favor, confirma tu acción.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "Estás a punto de eliminar {{count}} archivo(s) seleccionado(s) de la biblioteca. Seguirán disponibles en Todos los archivos. Confirma para continuar.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Fecha de creación",
|
||||
"FileManager.title.size": "Tamaño",
|
||||
"FileManager.title.title": "Archivo",
|
||||
"FileManager.total.allSelectedCount": "Están seleccionados los {{count}} elementos.",
|
||||
"FileManager.total.allSelectedFallback": "Están seleccionados todos los resultados.",
|
||||
"FileManager.total.fileCount": "Total {{count}} elementos",
|
||||
"FileManager.total.loadedSelectedCount": "Seleccionados {{count}} elementos cargados.",
|
||||
"FileManager.total.selectAll": "Seleccionar los {{count}} elementos",
|
||||
"FileManager.total.selectAllFallback": "Seleccionar todos los elementos",
|
||||
"FileManager.total.selectedCount": "{{count}} elementos seleccionados",
|
||||
"FileManager.view.list": "Vista de lista",
|
||||
"FileManager.view.masonry": "Vista de cuadrícula",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "Recursos",
|
||||
"toggleLeftPanel": "Mostrar/Ocultar panel izquierdo",
|
||||
"uploadDock.body.collapse": "Colapsar",
|
||||
"uploadDock.header.cancelAll": "Cancelar todo",
|
||||
"uploadDock.body.item.cancel": "Cancelar",
|
||||
"uploadDock.body.item.cancelled": "Cancelado",
|
||||
"uploadDock.body.item.done": "Subido",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "فایل را به چند بخش متنی تقسیم کرده و آنها را برای جستجوی معنایی و گفتوگوی فایل جاسازی میکند.",
|
||||
"FileManager.actions.chunkingUnsupported": "این فایل از تقسیمبندی پشتیبانی نمیکند.",
|
||||
"FileManager.actions.confirmDelete": "در حال حذف این فایل هستید. پس از حذف، قابل بازیابی نخواهد بود. لطفاً اقدام خود را تأیید کنید.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "در آستانه حذف همه نتایج در نمای فعلی هستید. پس از حذف، قابل بازیابی نیستند. لطفاً اقدام خود را تأیید کنید.",
|
||||
"FileManager.actions.confirmDeleteFolder": "در حال حذف این پوشه و تمام محتوای آن هستید. این عملیات قابل بازگشت نیست. لطفاً تصمیم خود را تأیید کنید.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "در حال حذف {{count}} فایل انتخابشده هستید. پس از حذف، قابل بازیابی نخواهند بود. لطفاً اقدام خود را تأیید کنید.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "در حال حذف {{count}} فایل انتخابشده از کتابخانه هستید. این فایلها همچنان در بخش همه فایلها در دسترس خواهند بود. برای ادامه تأیید کنید.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "تاریخ ایجاد",
|
||||
"FileManager.title.size": "اندازه",
|
||||
"FileManager.title.title": "فایل",
|
||||
"FileManager.total.allSelectedCount": "همه {{count}} مورد انتخاب شدهاند.",
|
||||
"FileManager.total.allSelectedFallback": "همه نتایج انتخاب شدهاند.",
|
||||
"FileManager.total.fileCount": "مجموع {{count}} مورد",
|
||||
"FileManager.total.loadedSelectedCount": "{{count}} مورد بارگذاریشده انتخاب شدهاند.",
|
||||
"FileManager.total.selectAll": "انتخاب همه {{count}} مورد",
|
||||
"FileManager.total.selectAllFallback": "انتخاب همه موارد",
|
||||
"FileManager.total.selectedCount": "{{count}} مورد انتخاب شده",
|
||||
"FileManager.view.list": "نمای لیستی",
|
||||
"FileManager.view.masonry": "نمای جدولی",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "منابع",
|
||||
"toggleLeftPanel": "نمایش/مخفی کردن پنل کناری",
|
||||
"uploadDock.body.collapse": "جمع کردن",
|
||||
"uploadDock.header.cancelAll": "لغو همه",
|
||||
"uploadDock.body.item.cancel": "لغو",
|
||||
"uploadDock.body.item.cancelled": "لغو شد",
|
||||
"uploadDock.body.item.done": "بارگذاری شد",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Divise le fichier en plusieurs segments de texte et les intègre pour la recherche sémantique et le dialogue de fichier.",
|
||||
"FileManager.actions.chunkingUnsupported": "Ce fichier ne prend pas en charge le découpage.",
|
||||
"FileManager.actions.confirmDelete": "Vous êtes sur le point de supprimer ce fichier. Une fois supprimé, il ne pourra pas être récupéré. Veuillez confirmer votre action.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "Vous êtes sur le point de supprimer tous les résultats de la vue actuelle. Une fois supprimés, ils ne pourront pas être récupérés. Veuillez confirmer votre action.",
|
||||
"FileManager.actions.confirmDeleteFolder": "Vous êtes sur le point de supprimer ce dossier et tout son contenu. Cette action est irréversible. Veuillez confirmer votre décision.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "Vous êtes sur le point de supprimer les {{count}} fichiers sélectionnés. Une fois supprimés, ils ne pourront pas être récupérés. Veuillez confirmer votre action.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "Vous êtes sur le point de retirer {{count}} fichier(s) sélectionné(s) de la bibliothèque. Ils resteront disponibles dans Tous les fichiers. Confirmez pour continuer.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Créé le",
|
||||
"FileManager.title.size": "Taille",
|
||||
"FileManager.title.title": "Fichier",
|
||||
"FileManager.total.allSelectedCount": "Les {{count}} éléments sont tous sélectionnés.",
|
||||
"FileManager.total.allSelectedFallback": "Tous les résultats sont sélectionnés.",
|
||||
"FileManager.total.fileCount": "Total de {{count}} éléments",
|
||||
"FileManager.total.loadedSelectedCount": "{{count}} éléments chargés sélectionnés.",
|
||||
"FileManager.total.selectAll": "Sélectionner les {{count}} éléments",
|
||||
"FileManager.total.selectAllFallback": "Sélectionner tous les éléments",
|
||||
"FileManager.total.selectedCount": "{{count}} éléments sélectionnés",
|
||||
"FileManager.view.list": "Vue en liste",
|
||||
"FileManager.view.masonry": "Vue en grille",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "Ressources",
|
||||
"toggleLeftPanel": "Afficher/Masquer le panneau gauche",
|
||||
"uploadDock.body.collapse": "Réduire",
|
||||
"uploadDock.header.cancelAll": "Tout annuler",
|
||||
"uploadDock.body.item.cancel": "Annuler",
|
||||
"uploadDock.body.item.cancelled": "Annulé",
|
||||
"uploadDock.body.item.done": "Importé",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Dividi il file in più segmenti di testo ed esegui l'embedding per la ricerca semantica e il dialogo sui file.",
|
||||
"FileManager.actions.chunkingUnsupported": "Questo file non supporta la segmentazione.",
|
||||
"FileManager.actions.confirmDelete": "Stai per eliminare questo file. Una volta eliminato, non potrà essere recuperato. Conferma l'azione.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "Stai per eliminare tutti i risultati nella vista corrente. Una volta eliminati, non potranno essere recuperati. Conferma l'azione.",
|
||||
"FileManager.actions.confirmDeleteFolder": "Stai per eliminare questa cartella e tutto il suo contenuto. Questa azione è irreversibile. Conferma la tua decisione.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "Stai per eliminare i {{count}} file selezionati. Una volta eliminati, non potranno essere recuperati. Conferma l'azione.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "Stai per rimuovere {{count}} file selezionato/i dalla Libreria. Saranno comunque disponibili in Tutti i File. Conferma per continuare.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Data di Creazione",
|
||||
"FileManager.title.size": "Dimensione",
|
||||
"FileManager.title.title": "File",
|
||||
"FileManager.total.allSelectedCount": "Tutti i {{count}} elementi sono selezionati.",
|
||||
"FileManager.total.allSelectedFallback": "Tutti i risultati sono selezionati.",
|
||||
"FileManager.total.fileCount": "Totale {{count}} elementi",
|
||||
"FileManager.total.loadedSelectedCount": "Selezionati {{count}} elementi caricati.",
|
||||
"FileManager.total.selectAll": "Seleziona tutti i {{count}} elementi",
|
||||
"FileManager.total.selectAllFallback": "Seleziona tutti gli elementi",
|
||||
"FileManager.total.selectedCount": "{{count}} elementi selezionati",
|
||||
"FileManager.view.list": "Vista Elenco",
|
||||
"FileManager.view.masonry": "Vista Griglia",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "Risorse",
|
||||
"toggleLeftPanel": "Mostra/Nascondi pannello sinistro",
|
||||
"uploadDock.body.collapse": "Comprimi",
|
||||
"uploadDock.header.cancelAll": "Annulla tutto",
|
||||
"uploadDock.body.item.cancel": "Annulla",
|
||||
"uploadDock.body.item.cancelled": "Annullato",
|
||||
"uploadDock.body.item.done": "Caricato",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "ファイルを複数のテキストブロックに分割し、ベクトル化した後、意味検索やファイル対話に使用できます",
|
||||
"FileManager.actions.chunkingUnsupported": "このファイルはチャンク分割をサポートしていません",
|
||||
"FileManager.actions.confirmDelete": "このファイルを削除しようとしています。削除後は復元できませんので、操作を確認してください",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "現在の表示内のすべての結果を削除しようとしています。削除すると元に戻せません。操作を確認してください",
|
||||
"FileManager.actions.confirmDeleteFolder": "このフォルダーとそのすべての内容を削除しようとしています。削除後は元に戻せません。本当に実行しますか?",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "選択した {{count}} 個のファイルを削除しようとしています。削除後は復元できませんので、操作を確認してください",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "{{count}} 件の選択されたファイルをライブラリから削除しようとしています。ファイルは「すべてのファイル」には引き続き表示されます。続行するには確認してください。",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "作成日時",
|
||||
"FileManager.title.size": "サイズ",
|
||||
"FileManager.title.title": "ファイル",
|
||||
"FileManager.total.allSelectedCount": "全 {{count}} 件を選択済みです。",
|
||||
"FileManager.total.allSelectedFallback": "すべての結果を選択済みです。",
|
||||
"FileManager.total.fileCount": "合計 {{count}} 件",
|
||||
"FileManager.total.loadedSelectedCount": "読み込み済みの {{count}} 件を選択済みです。",
|
||||
"FileManager.total.selectAll": "全 {{count}} 件を選択",
|
||||
"FileManager.total.selectAllFallback": "すべての項目を選択",
|
||||
"FileManager.total.selectedCount": "選択済み {{count}} 件",
|
||||
"FileManager.view.list": "リスト表示",
|
||||
"FileManager.view.masonry": "グリッド表示",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "リソース",
|
||||
"toggleLeftPanel": "左パネルの表示/非表示を切り替え",
|
||||
"uploadDock.body.collapse": "折りたたむ",
|
||||
"uploadDock.header.cancelAll": "すべてキャンセル",
|
||||
"uploadDock.body.item.cancel": "キャンセル",
|
||||
"uploadDock.body.item.cancelled": "キャンセルされました",
|
||||
"uploadDock.body.item.done": "アップロード完了",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "파일을 여러 텍스트 블록으로 분할하고 벡터화하여 의미 기반 검색 및 파일 대화에 사용할 수 있습니다.",
|
||||
"FileManager.actions.chunkingUnsupported": "이 파일은 분할을 지원하지 않습니다.",
|
||||
"FileManager.actions.confirmDelete": "이 파일을 삭제하려고 합니다. 삭제 후에는 복구할 수 없습니다. 계속하시겠습니까?",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "현재 보기의 모든 결과를 삭제하려고 합니다. 삭제 후에는 복구할 수 없습니다. 작업을 확인해 주세요.",
|
||||
"FileManager.actions.confirmDeleteFolder": "이 폴더와 그 안의 모든 내용을 삭제하려고 합니다. 삭제 후에는 복구할 수 없습니다. 계속하시겠습니까?",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "선택한 {{count}}개의 파일을 삭제하려고 합니다. 삭제 후에는 복구할 수 없습니다. 계속하시겠습니까?",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "선택한 파일 {{count}}개를 라이브러리에서 제거하려고 합니다. 이 파일들은 '모든 파일'에서 계속 확인할 수 있습니다. 계속하려면 확인을 눌러주세요.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "생성일",
|
||||
"FileManager.title.size": "크기",
|
||||
"FileManager.title.title": "파일",
|
||||
"FileManager.total.allSelectedCount": "총 {{count}}개 항목이 모두 선택되었습니다.",
|
||||
"FileManager.total.allSelectedFallback": "모든 결과가 선택되었습니다.",
|
||||
"FileManager.total.fileCount": "총 {{count}}개 항목",
|
||||
"FileManager.total.loadedSelectedCount": "불러온 {{count}}개 항목이 선택되었습니다.",
|
||||
"FileManager.total.selectAll": "총 {{count}}개 항목 선택",
|
||||
"FileManager.total.selectAllFallback": "모든 항목 선택",
|
||||
"FileManager.total.selectedCount": "{{count}}개 선택됨",
|
||||
"FileManager.view.list": "목록 보기",
|
||||
"FileManager.view.masonry": "그리드 보기",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "리소스",
|
||||
"toggleLeftPanel": "왼쪽 패널 표시/숨기기",
|
||||
"uploadDock.body.collapse": "접기",
|
||||
"uploadDock.header.cancelAll": "모두 취소",
|
||||
"uploadDock.body.item.cancel": "취소",
|
||||
"uploadDock.body.item.cancelled": "취소됨",
|
||||
"uploadDock.body.item.done": "업로드 완료",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Splits het bestand in meerdere tekstfragmenten en voorzie ze van embedding voor semantisch zoeken en bestandsdialoog.",
|
||||
"FileManager.actions.chunkingUnsupported": "Dit bestand ondersteunt geen verdeling.",
|
||||
"FileManager.actions.confirmDelete": "Je staat op het punt dit bestand te verwijderen. Na verwijdering kan het niet worden hersteld. Bevestig je actie.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "U staat op het punt alle resultaten in de huidige weergave te verwijderen. Na verwijdering kunnen ze niet worden hersteld. Bevestig uw actie.",
|
||||
"FileManager.actions.confirmDeleteFolder": "Je staat op het punt deze map en alle inhoud te verwijderen. Deze actie kan niet ongedaan worden gemaakt. Bevestig je beslissing.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "Je staat op het punt de geselecteerde {{count}} bestanden te verwijderen. Na verwijdering kunnen ze niet worden hersteld. Bevestig je actie.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "Je staat op het punt om {{count}} geselecteerd(e) bestand(en) uit de bibliotheek te verwijderen. Ze blijven beschikbaar in Alle bestanden. Bevestig om door te gaan.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Aangemaakt op",
|
||||
"FileManager.title.size": "Grootte",
|
||||
"FileManager.title.title": "Bestand",
|
||||
"FileManager.total.allSelectedCount": "Alle {{count}} items zijn geselecteerd.",
|
||||
"FileManager.total.allSelectedFallback": "Alle resultaten zijn geselecteerd.",
|
||||
"FileManager.total.fileCount": "Totaal {{count}} items",
|
||||
"FileManager.total.loadedSelectedCount": "{{count}} geladen items geselecteerd.",
|
||||
"FileManager.total.selectAll": "Alle {{count}} items selecteren",
|
||||
"FileManager.total.selectAllFallback": "Alle items selecteren",
|
||||
"FileManager.total.selectedCount": "{{count}} items geselecteerd",
|
||||
"FileManager.view.list": "Lijstweergave",
|
||||
"FileManager.view.masonry": "Rasterweergave",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "Bronnen",
|
||||
"toggleLeftPanel": "Zijpaneel tonen/verbergen",
|
||||
"uploadDock.body.collapse": "Inklappen",
|
||||
"uploadDock.header.cancelAll": "Alles annuleren",
|
||||
"uploadDock.body.item.cancel": "Annuleren",
|
||||
"uploadDock.body.item.cancelled": "Geannuleerd",
|
||||
"uploadDock.body.item.done": "Geüpload",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Podziel plik na wiele fragmentów tekstu i osadź je do wyszukiwania semantycznego i dialogu z plikiem.",
|
||||
"FileManager.actions.chunkingUnsupported": "Ten plik nie obsługuje dzielenia na fragmenty.",
|
||||
"FileManager.actions.confirmDelete": "Zamierzasz usunąć ten plik. Po usunięciu nie będzie można go odzyskać. Potwierdź swoją decyzję.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "Zamierzasz usunąć wszystkie wyniki w bieżącym widoku. Po usunięciu nie będzie można ich odzyskać. Potwierdź działanie.",
|
||||
"FileManager.actions.confirmDeleteFolder": "Zamierzasz usunąć ten folder wraz z całą jego zawartością. Tej operacji nie można cofnąć. Potwierdź swoją decyzję.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "Zamierzasz usunąć wybrane {{count}} pliki. Po usunięciu nie będzie można ich odzyskać. Potwierdź swoją decyzję.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "Zamierzasz usunąć {{count}} wybrany(-ch) plik(-ów) z biblioteki. Nadal będą dostępne w sekcji Wszystkie pliki. Potwierdź, aby kontynuować.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Utworzono",
|
||||
"FileManager.title.size": "Rozmiar",
|
||||
"FileManager.title.title": "Plik",
|
||||
"FileManager.total.allSelectedCount": "Wybrano wszystkie {{count}} elementy.",
|
||||
"FileManager.total.allSelectedFallback": "Wybrano wszystkie wyniki.",
|
||||
"FileManager.total.fileCount": "Łącznie {{count}} elementów",
|
||||
"FileManager.total.loadedSelectedCount": "Wybrano {{count}} załadowanych elementów.",
|
||||
"FileManager.total.selectAll": "Zaznacz wszystkie {{count}} elementy",
|
||||
"FileManager.total.selectAllFallback": "Zaznacz wszystkie elementy",
|
||||
"FileManager.total.selectedCount": "Wybrano {{count}} elementów",
|
||||
"FileManager.view.list": "Widok listy",
|
||||
"FileManager.view.masonry": "Widok siatki",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "Zasoby",
|
||||
"toggleLeftPanel": "Pokaż/Ukryj panel boczny",
|
||||
"uploadDock.body.collapse": "Zwiń",
|
||||
"uploadDock.header.cancelAll": "Anuluj wszystko",
|
||||
"uploadDock.body.item.cancel": "Anuluj",
|
||||
"uploadDock.body.item.cancelled": "Anulowano",
|
||||
"uploadDock.body.item.done": "Przesłano",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Divide o arquivo em vários trechos de texto e os incorpora para busca semântica e diálogo com arquivos.",
|
||||
"FileManager.actions.chunkingUnsupported": "Este arquivo não suporta divisão.",
|
||||
"FileManager.actions.confirmDelete": "Você está prestes a excluir este arquivo. Uma vez excluído, não poderá ser recuperado. Confirme sua ação.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "Você está prestes a excluir todos os resultados da visualização atual. Depois de excluídos, eles não poderão ser recuperados. Confirme sua ação.",
|
||||
"FileManager.actions.confirmDeleteFolder": "Você está prestes a excluir esta pasta e todo o seu conteúdo. Esta ação não pode ser desfeita. Confirme sua decisão.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "Você está prestes a excluir os {{count}} arquivos selecionados. Uma vez excluídos, não poderão ser recuperados. Confirme sua ação.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "Você está prestes a remover {{count}} arquivo(s) selecionado(s) da Biblioteca. Eles ainda estarão disponíveis em Todos os Arquivos. Confirme para continuar.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Criado em",
|
||||
"FileManager.title.size": "Tamanho",
|
||||
"FileManager.title.title": "Arquivo",
|
||||
"FileManager.total.allSelectedCount": "Todos os {{count}} itens estão selecionados.",
|
||||
"FileManager.total.allSelectedFallback": "Todos os resultados estão selecionados.",
|
||||
"FileManager.total.fileCount": "Total de {{count}} itens",
|
||||
"FileManager.total.loadedSelectedCount": "{{count}} itens carregados selecionados.",
|
||||
"FileManager.total.selectAll": "Selecionar todos os {{count}} itens",
|
||||
"FileManager.total.selectAllFallback": "Selecionar todos os itens",
|
||||
"FileManager.total.selectedCount": "{{count}} itens selecionados",
|
||||
"FileManager.view.list": "Visualização em Lista",
|
||||
"FileManager.view.masonry": "Visualização em Grade",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "Recursos",
|
||||
"toggleLeftPanel": "Mostrar/Ocultar Painel Esquerdo",
|
||||
"uploadDock.body.collapse": "Recolher",
|
||||
"uploadDock.header.cancelAll": "Cancelar tudo",
|
||||
"uploadDock.body.item.cancel": "Cancelar",
|
||||
"uploadDock.body.item.cancelled": "Cancelado",
|
||||
"uploadDock.body.item.done": "Enviado",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Разделить файл на несколько текстовых фрагментов и встроить их для семантического поиска и диалога с файлом.",
|
||||
"FileManager.actions.chunkingUnsupported": "Этот файл не поддерживает сегментацию.",
|
||||
"FileManager.actions.confirmDelete": "Вы собираетесь удалить этот файл. После удаления восстановление будет невозможно. Подтвердите действие.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "Вы собираетесь удалить все результаты в текущем представлении. После удаления их нельзя будет восстановить. Пожалуйста, подтвердите действие.",
|
||||
"FileManager.actions.confirmDeleteFolder": "Вы собираетесь удалить эту папку и всё её содержимое. Это действие необратимо. Подтвердите решение.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "Вы собираетесь удалить выбранные {{count}} файлов. После удаления восстановление будет невозможно. Подтвердите действие.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "Вы собираетесь удалить {{count}} выбранный(е) файл(ы) из библиотеки. Они останутся доступны во всех файлах. Подтвердите, чтобы продолжить.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Дата создания",
|
||||
"FileManager.title.size": "Размер",
|
||||
"FileManager.title.title": "Файл",
|
||||
"FileManager.total.allSelectedCount": "Все {{count}} элементов выбраны.",
|
||||
"FileManager.total.allSelectedFallback": "Выбраны все результаты.",
|
||||
"FileManager.total.fileCount": "Всего {{count}} элементов",
|
||||
"FileManager.total.loadedSelectedCount": "Выбрано {{count}} загруженных элементов.",
|
||||
"FileManager.total.selectAll": "Выбрать все {{count}} элементов",
|
||||
"FileManager.total.selectAllFallback": "Выбрать все элементы",
|
||||
"FileManager.total.selectedCount": "Выбрано {{count}} элементов",
|
||||
"FileManager.view.list": "Список",
|
||||
"FileManager.view.masonry": "Сетка",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "Ресурсы",
|
||||
"toggleLeftPanel": "Показать/Скрыть левую панель",
|
||||
"uploadDock.body.collapse": "Свернуть",
|
||||
"uploadDock.header.cancelAll": "Отменить все",
|
||||
"uploadDock.body.item.cancel": "Отменить",
|
||||
"uploadDock.body.item.cancelled": "Отменено",
|
||||
"uploadDock.body.item.done": "Загружено",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Dosyayı birden fazla metin parçasına bölerek semantik arama ve dosya diyaloğu için gömülü hale getirir.",
|
||||
"FileManager.actions.chunkingUnsupported": "Bu dosya parçalamayı desteklemiyor.",
|
||||
"FileManager.actions.confirmDelete": "Bu dosyayı silmek üzeresiniz. Silindikten sonra geri alınamaz. Lütfen işlemi onaylayın.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "Geçerli görünümdeki tüm sonuçları silmek üzeresiniz. Silindikten sonra geri getirilemezler. Lütfen işlemi onaylayın.",
|
||||
"FileManager.actions.confirmDeleteFolder": "Bu klasörü ve içindeki tüm öğeleri silmek üzeresiniz. Bu işlem geri alınamaz. Lütfen kararınızı onaylayın.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "Seçilen {{count}} dosyayı silmek üzeresiniz. Silindikten sonra geri alınamazlar. Lütfen işlemi onaylayın.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "{{count}} seçili dosyayı Kütüphane'den kaldırmak üzeresiniz. Dosyalar Tüm Dosyalar bölümünde erişilebilir olmaya devam edecek. Devam etmek için onaylayın.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Oluşturulma Tarihi",
|
||||
"FileManager.title.size": "Boyut",
|
||||
"FileManager.title.title": "Dosya",
|
||||
"FileManager.total.allSelectedCount": "Tüm {{count}} öğe seçildi.",
|
||||
"FileManager.total.allSelectedFallback": "Tüm sonuçlar seçildi.",
|
||||
"FileManager.total.fileCount": "Toplam {{count}} öğe",
|
||||
"FileManager.total.loadedSelectedCount": "Yüklenen {{count}} öğe seçildi.",
|
||||
"FileManager.total.selectAll": "Tüm {{count}} öğeyi seç",
|
||||
"FileManager.total.selectAllFallback": "Tüm öğeleri seç",
|
||||
"FileManager.total.selectedCount": "Seçilen {{count}} öğe",
|
||||
"FileManager.view.list": "Liste Görünümü",
|
||||
"FileManager.view.masonry": "Izgara Görünümü",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "Kaynaklar",
|
||||
"toggleLeftPanel": "Sol Paneli Göster/Gizle",
|
||||
"uploadDock.body.collapse": "Daralt",
|
||||
"uploadDock.header.cancelAll": "Tümünü iptal et",
|
||||
"uploadDock.body.item.cancel": "İptal Et",
|
||||
"uploadDock.body.item.cancelled": "İptal Edildi",
|
||||
"uploadDock.body.item.done": "Yüklendi",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "Chia tệp thành nhiều đoạn văn bản và nhúng chúng để tìm kiếm ngữ nghĩa và đối thoại với tệp.",
|
||||
"FileManager.actions.chunkingUnsupported": "Tệp này không hỗ trợ phân đoạn.",
|
||||
"FileManager.actions.confirmDelete": "Bạn sắp xóa tệp này. Sau khi xóa, không thể khôi phục. Vui lòng xác nhận hành động của bạn.",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "Bạn sắp xóa toàn bộ kết quả trong chế độ xem hiện tại. Sau khi xóa, chúng sẽ không thể khôi phục. Vui lòng xác nhận thao tác.",
|
||||
"FileManager.actions.confirmDeleteFolder": "Bạn sắp xóa thư mục này và toàn bộ nội dung bên trong. Hành động này không thể hoàn tác. Vui lòng xác nhận quyết định của bạn.",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "Bạn sắp xóa {{count}} tệp đã chọn. Sau khi xóa, chúng không thể khôi phục. Vui lòng xác nhận hành động của bạn.",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "Bạn sắp xóa {{count}} tệp đã chọn khỏi Thư viện. Chúng vẫn sẽ có sẵn trong Tất cả Tệp. Xác nhận để tiếp tục.",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "Ngày tạo",
|
||||
"FileManager.title.size": "Kích thước",
|
||||
"FileManager.title.title": "Tệp",
|
||||
"FileManager.total.allSelectedCount": "Đã chọn tất cả {{count}} mục.",
|
||||
"FileManager.total.allSelectedFallback": "Đã chọn tất cả kết quả.",
|
||||
"FileManager.total.fileCount": "Tổng cộng {{count}} mục",
|
||||
"FileManager.total.loadedSelectedCount": "Đã chọn {{count}} mục đã tải.",
|
||||
"FileManager.total.selectAll": "Chọn tất cả {{count}} mục",
|
||||
"FileManager.total.selectAllFallback": "Chọn tất cả mục",
|
||||
"FileManager.total.selectedCount": "Đã chọn {{count}} mục",
|
||||
"FileManager.view.list": "Chế độ danh sách",
|
||||
"FileManager.view.masonry": "Chế độ lưới",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "Tài Nguyên",
|
||||
"toggleLeftPanel": "Hiện/Ẩn Bảng Bên Trái",
|
||||
"uploadDock.body.collapse": "Thu Gọn",
|
||||
"uploadDock.header.cancelAll": "Hủy tất cả",
|
||||
"uploadDock.body.item.cancel": "Hủy",
|
||||
"uploadDock.body.item.cancelled": "Đã hủy",
|
||||
"uploadDock.body.item.done": "Đã tải lên",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "将文件拆分为多个文本块并向量化后,可用于语义检索和文件对话",
|
||||
"FileManager.actions.chunkingUnsupported": "该文件不支持分块",
|
||||
"FileManager.actions.confirmDelete": "将删除该文件,删除后不可恢复。建议确认无误再继续",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "将删除当前结果中的全部数据,删除后不可恢复。建议确认无误再继续",
|
||||
"FileManager.actions.confirmDeleteFolder": "将删除该文件夹及其内容,删除后不可恢复。建议确认无误再继续",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "将删除选中的 {{count}} 个文件,删除后不可恢复。建议确认无误再继续",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "您即将从资料库中移除 {{count}} 个已选文件。它们仍可在“所有文件”中访问。请确认以继续。",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "创建时间",
|
||||
"FileManager.title.size": "大小",
|
||||
"FileManager.title.title": "文件",
|
||||
"FileManager.total.allSelectedCount": "已选中全部 {{count}} 项。",
|
||||
"FileManager.total.allSelectedFallback": "已选中全部结果。",
|
||||
"FileManager.total.fileCount": "共 {{count}} 项",
|
||||
"FileManager.total.loadedSelectedCount": "已选中当前已加载的 {{count}} 项。",
|
||||
"FileManager.total.selectAll": "选择全部 {{count}} 项",
|
||||
"FileManager.total.selectAllFallback": "选择全部项目",
|
||||
"FileManager.total.selectedCount": "已选 {{count}} 项",
|
||||
"FileManager.view.list": "列表视图",
|
||||
"FileManager.view.masonry": "网格视图",
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@
|
|||
"uploadDock.body.item.processing": "文件处理中…",
|
||||
"uploadDock.body.item.restTime": "剩余 {{time}}",
|
||||
"uploadDock.fileQueueInfo": "正在上传前 {{count}} 个文件,剩余 {{remaining}} 个文件将排队上传",
|
||||
"uploadDock.header.cancelAll": "全部取消",
|
||||
"uploadDock.totalCount": "共 {{count}} 项",
|
||||
"uploadDock.uploadStatus.cancelled": "上传已取消",
|
||||
"uploadDock.uploadStatus.error": "上传出错",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"FileManager.actions.chunkingTooltip": "將文件拆分為多個文本塊並向量化後,可用於語義檢索和文件對話",
|
||||
"FileManager.actions.chunkingUnsupported": "該檔案不支援分塊",
|
||||
"FileManager.actions.confirmDelete": "即將刪除該文件,刪除後將無法找回,請確認你的操作",
|
||||
"FileManager.actions.confirmDeleteAllFiles": "將刪除目前結果中的全部資料,刪除後不可復原。建議確認無誤再繼續",
|
||||
"FileManager.actions.confirmDeleteFolder": "即將刪除該資料夾及其所有內容,刪除後將無法恢復,請確認你的操作",
|
||||
"FileManager.actions.confirmDeleteMultiFiles": "即將刪除選中的 {{count}} 個文件,刪除後將無法找回,請確認你的操作",
|
||||
"FileManager.actions.confirmRemoveFromLibrary": "您即將從資料庫中移除 {{count}} 個已選取的檔案。這些檔案仍會保留在「所有檔案」中。請確認以繼續。",
|
||||
|
|
@ -51,7 +52,12 @@
|
|||
"FileManager.title.createdAt": "創建時間",
|
||||
"FileManager.title.size": "大小",
|
||||
"FileManager.title.title": "文件",
|
||||
"FileManager.total.allSelectedCount": "已選取全部 {{count}} 項。",
|
||||
"FileManager.total.allSelectedFallback": "已選取全部結果。",
|
||||
"FileManager.total.fileCount": "共 {{count}} 項",
|
||||
"FileManager.total.loadedSelectedCount": "已選取目前已載入的 {{count}} 項。",
|
||||
"FileManager.total.selectAll": "選取全部 {{count}} 項",
|
||||
"FileManager.total.selectAllFallback": "選取全部項目",
|
||||
"FileManager.total.selectedCount": "已選 {{count}} 項",
|
||||
"FileManager.view.list": "清單檢視",
|
||||
"FileManager.view.masonry": "網格檢視",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"title": "資源",
|
||||
"toggleLeftPanel": "顯示/隱藏左側面板",
|
||||
"uploadDock.body.collapse": "收起",
|
||||
"uploadDock.header.cancelAll": "全部取消",
|
||||
"uploadDock.body.item.cancel": "取消",
|
||||
"uploadDock.body.item.cancelled": "已取消",
|
||||
"uploadDock.body.item.done": "已上傳",
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ export interface KnowledgeItem {
|
|||
chunkTaskId?: string | null;
|
||||
content?: string | null;
|
||||
createdAt: Date;
|
||||
documentId?: string | null;
|
||||
editorData?: Record<string, any> | null;
|
||||
embeddingTaskId?: string | null;
|
||||
fileId?: string | null;
|
||||
fileType: string;
|
||||
id: string;
|
||||
metadata?: Record<string, any> | null;
|
||||
|
|
@ -136,8 +138,10 @@ export class KnowledgeRepo {
|
|||
chunkTaskId: row.chunk_task_id,
|
||||
content: row.content,
|
||||
createdAt: new Date(row.created_at),
|
||||
documentId: row.document_id,
|
||||
editorData,
|
||||
embeddingTaskId: row.embedding_task_id,
|
||||
fileId: row.file_id,
|
||||
fileType: row.file_type,
|
||||
id: row.id,
|
||||
metadata,
|
||||
|
|
@ -161,6 +165,8 @@ export class KnowledgeRepo {
|
|||
const fileQuery = sql`
|
||||
SELECT
|
||||
COALESCE(d.id, f.id) as id,
|
||||
f.id as file_id,
|
||||
d.id as document_id,
|
||||
f.name,
|
||||
f.file_type,
|
||||
f.size,
|
||||
|
|
@ -187,6 +193,8 @@ export class KnowledgeRepo {
|
|||
const documentQuery = sql`
|
||||
SELECT
|
||||
id,
|
||||
file_id,
|
||||
id as document_id,
|
||||
COALESCE(title, filename, 'Untitled') as name,
|
||||
file_type,
|
||||
total_char_count as size,
|
||||
|
|
@ -243,8 +251,10 @@ export class KnowledgeRepo {
|
|||
chunkTaskId: row.chunk_task_id,
|
||||
content: row.content,
|
||||
createdAt: new Date(row.created_at),
|
||||
documentId: row.document_id,
|
||||
editorData,
|
||||
embeddingTaskId: row.embedding_task_id,
|
||||
fileId: row.file_id,
|
||||
fileType: row.file_type,
|
||||
id: row.id,
|
||||
metadata,
|
||||
|
|
@ -369,6 +379,8 @@ export class KnowledgeRepo {
|
|||
return sql`
|
||||
SELECT
|
||||
COALESCE(d.id, f.id) as id,
|
||||
f.id as file_id,
|
||||
d.id as document_id,
|
||||
f.name,
|
||||
f.file_type,
|
||||
f.size,
|
||||
|
|
@ -407,6 +419,8 @@ export class KnowledgeRepo {
|
|||
return sql`
|
||||
SELECT
|
||||
COALESCE(d.id, f.id) as id,
|
||||
f.id as file_id,
|
||||
d.id as document_id,
|
||||
f.name,
|
||||
f.file_type,
|
||||
f.size,
|
||||
|
|
@ -475,6 +489,8 @@ export class KnowledgeRepo {
|
|||
return sql`
|
||||
SELECT
|
||||
NULL::varchar(30) as id,
|
||||
NULL::varchar(30) as file_id,
|
||||
NULL::varchar(30) as document_id,
|
||||
NULL::text as name,
|
||||
NULL::varchar(255) as file_type,
|
||||
NULL::integer as size,
|
||||
|
|
@ -536,6 +552,8 @@ export class KnowledgeRepo {
|
|||
return sql`
|
||||
SELECT
|
||||
NULL::varchar(30) as id,
|
||||
NULL::varchar(30) as file_id,
|
||||
NULL::varchar(30) as document_id,
|
||||
NULL::text as name,
|
||||
NULL::varchar(255) as file_type,
|
||||
NULL::integer as size,
|
||||
|
|
@ -562,6 +580,8 @@ export class KnowledgeRepo {
|
|||
return sql`
|
||||
SELECT
|
||||
d.id,
|
||||
d.file_id,
|
||||
d.id as document_id,
|
||||
COALESCE(d.title, d.filename, 'Untitled') as name,
|
||||
d.file_type,
|
||||
d.total_char_count as size,
|
||||
|
|
@ -583,6 +603,8 @@ export class KnowledgeRepo {
|
|||
return sql`
|
||||
SELECT
|
||||
id,
|
||||
file_id,
|
||||
id as document_id,
|
||||
COALESCE(title, filename, 'Untitled') as name,
|
||||
file_type,
|
||||
total_char_count as size,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import type { AsyncTaskStatus } from '../asyncTask';
|
||||
import type { AsyncTaskStatus, FileParsingTask } from '../asyncTask';
|
||||
|
||||
export interface KnowledgeItemStatus extends FileParsingTask {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface FileListItem {
|
||||
chunkCount: number | null;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'antd/dist/reset.css';
|
|||
|
||||
import { ConfigProvider, ThemeProvider } from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import * as motion from 'motion/react-m';
|
||||
import * as m from 'motion/react-m';
|
||||
import Link from 'next/link';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
|
@ -35,7 +35,7 @@ const AuthThemeLite = memo<AuthThemeLiteProps>(({ children, globalCDN }) => {
|
|||
<App style={{ height: '100%' }}>
|
||||
<AntdStaticMethods />
|
||||
<ConfigProvider
|
||||
motion={motion}
|
||||
motion={m}
|
||||
config={{
|
||||
aAs: Link,
|
||||
imgAs: Image,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AnimatePresence, m as motion } from 'motion/react';
|
||||
import { AnimatePresence, m } from 'motion/react';
|
||||
import { type CSSProperties, type ReactNode } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ const AnimatedCollapsed = memo<AnimatedCollapsedProps>(
|
|||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate="open"
|
||||
exit="collapsed"
|
||||
initial="collapsed"
|
||||
|
|
@ -50,7 +50,7 @@ const AnimatedCollapsed = memo<AnimatedCollapsedProps>(
|
|||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Flexbox, Icon, SearchResultCards, Tag } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import { ChevronDown, ChevronRight, Globe, Images } from 'lucide-react';
|
||||
import { AnimatePresence, m as motion } from 'motion/react';
|
||||
import { AnimatePresence, m } from 'motion/react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -195,7 +195,7 @@ const SearchGrounding = memo<GroundingSearch>(
|
|||
|
||||
<AnimatePresence initial={false}>
|
||||
{showDetail && (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate="open"
|
||||
exit="collapsed"
|
||||
initial="collapsed"
|
||||
|
|
@ -292,7 +292,7 @@ const SearchGrounding = memo<GroundingSearch>(
|
|||
</div>
|
||||
)}
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Flexbox>
|
||||
|
|
|
|||
|
|
@ -11,91 +11,96 @@ interface CreateFormProps {
|
|||
fileIds: string[];
|
||||
knowledgeBaseId?: string;
|
||||
onClose?: () => void;
|
||||
resolveFileIds?: () => Promise<string[]>;
|
||||
selectedCount?: number;
|
||||
}
|
||||
|
||||
const SelectForm = memo<CreateFormProps>(({ onClose, knowledgeBaseId, fileIds }) => {
|
||||
const { t } = useTranslation('knowledgeBase');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const SelectForm = memo<CreateFormProps>(
|
||||
({ onClose, knowledgeBaseId, fileIds, resolveFileIds, selectedCount }) => {
|
||||
const { t } = useTranslation('knowledgeBase');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { message } = App.useApp();
|
||||
const [useFetchKnowledgeBaseList, addFilesToKnowledgeBase] = useKnowledgeBaseStore((s) => [
|
||||
s.useFetchKnowledgeBaseList,
|
||||
s.addFilesToKnowledgeBase,
|
||||
]);
|
||||
const { data, isLoading } = useFetchKnowledgeBaseList();
|
||||
const onFinish = async (values: { id: string }) => {
|
||||
setLoading(true);
|
||||
const { message } = App.useApp();
|
||||
const [useFetchKnowledgeBaseList, addFilesToKnowledgeBase] = useKnowledgeBaseStore((s) => [
|
||||
s.useFetchKnowledgeBaseList,
|
||||
s.addFilesToKnowledgeBase,
|
||||
]);
|
||||
const { data, isLoading } = useFetchKnowledgeBaseList();
|
||||
const onFinish = async (values: { id: string }) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await addFilesToKnowledgeBase(values.id, fileIds);
|
||||
setLoading(false);
|
||||
message.success({
|
||||
content: (
|
||||
<Trans
|
||||
i18nKey={'addToKnowledgeBase.addSuccess'}
|
||||
ns={'knowledgeBase'}
|
||||
components={[
|
||||
<span key="0" />,
|
||||
<Link key="1" to={`/knowledge/library/${values.id}`} />,
|
||||
]}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
onClose?.();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
gap={16}
|
||||
itemsType={'flat'}
|
||||
layout={'vertical'}
|
||||
footer={
|
||||
<Button block htmlType={'submit'} loading={loading} type={'primary'}>
|
||||
{t('addToKnowledgeBase.confirm')}
|
||||
</Button>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
children: (
|
||||
<Block horizontal align={'center'} gap={8} padding={16} variant={'filled'}>
|
||||
<MaterialFileTypeIcon filename={''} size={32} />
|
||||
{t('addToKnowledgeBase.totalFiles', { count: fileIds.length })}
|
||||
</Block>
|
||||
),
|
||||
noStyle: true,
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<Select
|
||||
autoFocus
|
||||
loading={isLoading}
|
||||
placeholder={t('addToKnowledgeBase.id.placeholder')}
|
||||
options={(data || [])
|
||||
.filter((item) => item.id !== knowledgeBaseId)
|
||||
.map((item) => ({
|
||||
label: (
|
||||
<Flexbox horizontal gap={8}>
|
||||
<RepoIcon />
|
||||
{item.name}
|
||||
</Flexbox>
|
||||
),
|
||||
value: item.id,
|
||||
}))}
|
||||
try {
|
||||
const effectiveFileIds = resolveFileIds ? await resolveFileIds() : fileIds;
|
||||
await addFilesToKnowledgeBase(values.id, effectiveFileIds);
|
||||
setLoading(false);
|
||||
message.success({
|
||||
content: (
|
||||
<Trans
|
||||
i18nKey={'addToKnowledgeBase.addSuccess'}
|
||||
ns={'knowledgeBase'}
|
||||
components={[
|
||||
<span key="0" />,
|
||||
<Link key="1" to={`/knowledge/library/${values.id}`} />,
|
||||
]}
|
||||
/>
|
||||
),
|
||||
label: t('addToKnowledgeBase.id.title'),
|
||||
name: 'id',
|
||||
rules: [{ message: t('addToKnowledgeBase.id.required'), required: true }],
|
||||
},
|
||||
]}
|
||||
onFinish={onFinish}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
onClose?.();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
gap={16}
|
||||
itemsType={'flat'}
|
||||
layout={'vertical'}
|
||||
footer={
|
||||
<Button block htmlType={'submit'} loading={loading} type={'primary'}>
|
||||
{t('addToKnowledgeBase.confirm')}
|
||||
</Button>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
children: (
|
||||
<Block horizontal align={'center'} gap={8} padding={16} variant={'filled'}>
|
||||
<MaterialFileTypeIcon filename={''} size={32} />
|
||||
{t('addToKnowledgeBase.totalFiles', { count: selectedCount ?? fileIds.length })}
|
||||
</Block>
|
||||
),
|
||||
noStyle: true,
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<Select
|
||||
autoFocus
|
||||
loading={isLoading}
|
||||
placeholder={t('addToKnowledgeBase.id.placeholder')}
|
||||
options={(data || [])
|
||||
.filter((item) => item.id !== knowledgeBaseId)
|
||||
.map((item) => ({
|
||||
label: (
|
||||
<Flexbox horizontal gap={8}>
|
||||
<RepoIcon />
|
||||
{item.name}
|
||||
</Flexbox>
|
||||
),
|
||||
value: item.id,
|
||||
}))}
|
||||
/>
|
||||
),
|
||||
label: t('addToKnowledgeBase.id.title'),
|
||||
name: 'id',
|
||||
rules: [{ message: t('addToKnowledgeBase.id.required'), required: true }],
|
||||
},
|
||||
]}
|
||||
onFinish={onFinish}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default SelectForm;
|
||||
|
|
|
|||
|
|
@ -9,28 +9,46 @@ interface AddFilesToKnowledgeBaseModalProps {
|
|||
fileIds: string[];
|
||||
knowledgeBaseId?: string;
|
||||
onClose?: () => void;
|
||||
resolveFileIds?: () => Promise<string[]>;
|
||||
selectedCount?: number;
|
||||
}
|
||||
|
||||
interface ModalContentProps {
|
||||
fileIds: string[];
|
||||
knowledgeBaseId?: string;
|
||||
resolveFileIds?: () => Promise<string[]>;
|
||||
selectedCount?: number;
|
||||
}
|
||||
|
||||
const ModalContent = memo<ModalContentProps>(({ fileIds, knowledgeBaseId }) => {
|
||||
const { t } = useTranslation('knowledgeBase');
|
||||
const { close } = useModalContext();
|
||||
return (
|
||||
<>
|
||||
<Flexbox horizontal gap={8} paddingBlock={16} paddingInline={16} style={{ paddingBottom: 0 }}>
|
||||
<Icon icon={BookUp2Icon} />
|
||||
{t('addToKnowledgeBase.title')}
|
||||
</Flexbox>
|
||||
<Flexbox padding={16} style={{ paddingTop: 0 }}>
|
||||
<SelectForm fileIds={fileIds} knowledgeBaseId={knowledgeBaseId} onClose={close} />
|
||||
</Flexbox>
|
||||
</>
|
||||
);
|
||||
});
|
||||
const ModalContent = memo<ModalContentProps>(
|
||||
({ fileIds, knowledgeBaseId, resolveFileIds, selectedCount }) => {
|
||||
const { t } = useTranslation('knowledgeBase');
|
||||
const { close } = useModalContext();
|
||||
return (
|
||||
<>
|
||||
<Flexbox
|
||||
horizontal
|
||||
gap={8}
|
||||
paddingBlock={16}
|
||||
paddingInline={16}
|
||||
style={{ paddingBottom: 0 }}
|
||||
>
|
||||
<Icon icon={BookUp2Icon} />
|
||||
{t('addToKnowledgeBase.title')}
|
||||
</Flexbox>
|
||||
<Flexbox padding={16} style={{ paddingTop: 0 }}>
|
||||
<SelectForm
|
||||
fileIds={fileIds}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
resolveFileIds={resolveFileIds}
|
||||
selectedCount={selectedCount}
|
||||
onClose={close}
|
||||
/>
|
||||
</Flexbox>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ModalContent.displayName = 'AddFilesToKnowledgeBaseModalContent';
|
||||
|
||||
|
|
@ -40,7 +58,12 @@ export const useAddFilesToKnowledgeBaseModal = () => {
|
|||
afterClose: params?.onClose,
|
||||
children: (
|
||||
<Suspense fallback={<div style={{ minHeight: 200 }} />}>
|
||||
<ModalContent fileIds={params?.fileIds || []} knowledgeBaseId={params?.knowledgeBaseId} />
|
||||
<ModalContent
|
||||
fileIds={params?.fileIds || []}
|
||||
knowledgeBaseId={params?.knowledgeBaseId}
|
||||
resolveFileIds={params?.resolveFileIds}
|
||||
selectedCount={params?.selectedCount}
|
||||
/>
|
||||
</Suspense>
|
||||
),
|
||||
footer: null,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Flexbox, Highlighter, Tag } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import * as motion from 'motion/react-m';
|
||||
import * as m from 'motion/react-m';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ const ErrorDetails = memo<{
|
|||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
|
|
@ -77,7 +77,7 @@ const ErrorDetails = memo<{
|
|||
</div>
|
||||
)}
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Button, Flexbox, Form, Markdown } from '@lobehub/ui';
|
||||
import { Form as AForm } from 'antd';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import * as motion from 'motion/react-m';
|
||||
import * as m from 'motion/react-m';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -71,13 +71,13 @@ const MCPConfigForm = memo<MCPConfigFormProps>(({ configSchema, identifier, onCa
|
|||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ y: 0 }}
|
||||
className={styles.container}
|
||||
initial={{ y: 8 }}
|
||||
transition={{ delay: 0.1, duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
transition={{ delay: 0.15, duration: 0.2 }}
|
||||
|
|
@ -88,9 +88,9 @@ const MCPConfigForm = memo<MCPConfigFormProps>(({ configSchema, identifier, onCa
|
|||
{t('mcpInstall.configurationDescription')}
|
||||
</span>
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
transition={{ delay: 0.2, duration: 0.2 }}
|
||||
|
|
@ -127,9 +127,9 @@ const MCPConfigForm = memo<MCPConfigFormProps>(({ configSchema, identifier, onCa
|
|||
}))}
|
||||
onFinish={handleSubmit}
|
||||
/>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={styles.footer}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
|
|
@ -141,8 +141,8 @@ const MCPConfigForm = memo<MCPConfigFormProps>(({ configSchema, identifier, onCa
|
|||
<Button loading={loading} size="small" type="primary" onClick={() => form.submit()}>
|
||||
{t('mcpInstall.continueInstall')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</m.div>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Button, Flexbox, Markdown, Snippet, Text } from '@lobehub/ui';
|
|||
import { Card, Space } from 'antd';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { AlertTriangle, CheckCircle, ExternalLink, Terminal } from 'lucide-react';
|
||||
import * as motion from 'motion/react-m';
|
||||
import * as m from 'motion/react-m';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -92,13 +92,13 @@ const MCPDependenciesGuide = memo<MCPDependenciesGuideProps>(
|
|||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ y: 0 }}
|
||||
className={styles.container}
|
||||
initial={{ y: 8 }}
|
||||
transition={{ delay: 0.1, duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
style={{ marginBottom: 8 }}
|
||||
|
|
@ -115,9 +115,9 @@ const MCPDependenciesGuide = memo<MCPDependenciesGuideProps>(
|
|||
{t('mcpInstall.dependenciesDescription')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
transition={{ delay: 0.2, duration: 0.2 }}
|
||||
|
|
@ -185,9 +185,9 @@ const MCPDependenciesGuide = memo<MCPDependenciesGuideProps>(
|
|||
</Card>
|
||||
))}
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={styles.footer}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
|
|
@ -206,8 +206,8 @@ const MCPDependenciesGuide = memo<MCPDependenciesGuideProps>(
|
|||
</Button>
|
||||
</Space>
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</m.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Flexbox, Text } from '@lobehub/ui';
|
|||
import { Progress } from 'antd';
|
||||
import { cssVar } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import * as motion from 'motion/react-m';
|
||||
import * as m from 'motion/react-m';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ const MCPInstallProgress = memo<{ identifier: string }>(({ identifier }) => {
|
|||
return (
|
||||
<>
|
||||
{installProgress && !hasError && !needsDependencies && (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
|
|
@ -54,11 +54,11 @@ const MCPInstallProgress = memo<{ identifier: string }>(({ identifier }) => {
|
|||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
|
||||
{hasError && errorInfo && (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
|
|
@ -71,11 +71,11 @@ const MCPInstallProgress = memo<{ identifier: string }>(({ identifier }) => {
|
|||
<Flexbox paddingBlock={8}>
|
||||
<InstallError errorInfo={errorInfo} identifier={identifier} />
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
|
||||
{needsDependencies && installProgress?.systemDependencies && (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
|
|
@ -91,11 +91,11 @@ const MCPInstallProgress = memo<{ identifier: string }>(({ identifier }) => {
|
|||
systemDependencies={installProgress.systemDependencies}
|
||||
/>
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
|
||||
{needsConfig && installProgress && (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
|
|
@ -106,7 +106,7 @@ const MCPInstallProgress = memo<{ identifier: string }>(({ identifier }) => {
|
|||
}}
|
||||
>
|
||||
<MCPConfigForm configSchema={installProgress.configSchema} identifier={identifier} />
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { DraggablePanel, Freeze } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { AnimatePresence, motion, useIsPresent } from 'motion/react';
|
||||
import { AnimatePresence, m, useIsPresent } from 'motion/react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { memo, Suspense, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
|
||||
|
|
@ -227,7 +227,7 @@ export const NavPanelDraggable = memo<NavPanelDraggableProps>(({ activeContent }
|
|||
<div className={draggableStyles.inner}>
|
||||
{shouldUseMotion ? (
|
||||
<AnimatePresence custom={motionDirection} initial={false} mode="sync">
|
||||
<motion.div
|
||||
<m.div
|
||||
animate="animate"
|
||||
className={draggableStyles.layer}
|
||||
custom={motionDirection}
|
||||
|
|
@ -238,7 +238,7 @@ export const NavPanelDraggable = memo<NavPanelDraggableProps>(({ activeContent }
|
|||
variants={motionVariants}
|
||||
>
|
||||
<ExitingFrozenContent>{activeContent.node}</ExitingFrozenContent>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<div className={draggableStyles.layer} key={activeContent.key}>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { pageAgentRuntime } from '@/store/tool/slices/builtin/executors/lobe-pag
|
|||
import { type PublicState } from './store';
|
||||
import { usePageEditorStore, useStoreApi } from './store';
|
||||
|
||||
type PageAgentEditor = NonNullable<Parameters<typeof pageAgentRuntime.setEditor>[0]>;
|
||||
|
||||
export interface StoreUpdaterProps extends Partial<PublicState> {
|
||||
pageId?: string;
|
||||
}
|
||||
|
|
@ -37,6 +39,7 @@ const StoreUpdater = memo<StoreUpdaterProps>(
|
|||
|
||||
const editor = usePageEditorStore((s) => s.editor);
|
||||
const initMeta = usePageEditorStore((s) => s.initMeta);
|
||||
const pageAgentEditor = editor as unknown as PageAgentEditor | undefined;
|
||||
|
||||
// Update store with props
|
||||
useStoreUpdater('documentId', pageId);
|
||||
|
|
@ -56,13 +59,13 @@ const StoreUpdater = memo<StoreUpdaterProps>(
|
|||
|
||||
// Connect editor to page agent runtime
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
pageAgentRuntime.setEditor(editor);
|
||||
if (pageAgentEditor) {
|
||||
pageAgentRuntime.setEditor(pageAgentEditor);
|
||||
}
|
||||
return () => {
|
||||
pageAgentRuntime.setEditor(null);
|
||||
};
|
||||
}, [editor]);
|
||||
}, [pageAgentEditor]);
|
||||
|
||||
// Connect title handlers and document ID to page agent runtime
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -100,11 +100,7 @@ const PageExplorerPlaceholder = memo<PageExplorerPlaceholderProps>(
|
|||
createDocument,
|
||||
currentFolderId: null,
|
||||
libraryId: knowledgeBaseId ?? null,
|
||||
refetchResources: async () => {
|
||||
const { revalidateResources } = await import('@/store/file/slices/resource/hooks');
|
||||
await revalidateResources();
|
||||
await fetchDocuments();
|
||||
},
|
||||
refetchResources: fetchDocuments,
|
||||
t,
|
||||
});
|
||||
|
||||
|
|
@ -113,10 +109,6 @@ const PageExplorerPlaceholder = memo<PageExplorerPlaceholderProps>(
|
|||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
await notionImport.handleNotionImport(event);
|
||||
// Fetch documents to update the UI immediately
|
||||
// The hook calls refreshFileList which invalidates SWR cache,
|
||||
// but we need to explicitly fetch to update the zustand store
|
||||
await fetchDocuments();
|
||||
};
|
||||
|
||||
const handleCreateDocument = async (content: string, title: string) => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next';
|
|||
|
||||
import NavHeader from '@/features/NavHeader';
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { getExplorerSelectedCount } from '@/routes/(main)/resource/features/store/selectors';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { FilesTabs } from '@/types/files';
|
||||
|
||||
import AddButton from '../../Header/AddButton';
|
||||
|
|
@ -26,17 +28,24 @@ const Header = memo(() => {
|
|||
const { modal, message } = App.useApp();
|
||||
|
||||
// Get state and actions from store
|
||||
const [libraryId, category, onActionClick, selectFileIds] = useResourceManagerStore((s) => [
|
||||
s.libraryId,
|
||||
s.category,
|
||||
s.onActionClick,
|
||||
s.selectedFileIds,
|
||||
]);
|
||||
const selectCount = selectFileIds.length;
|
||||
const isMultiSelected = selectCount > 1;
|
||||
const [libraryId, category, onActionClick, selectAllState, selectFileIds] =
|
||||
useResourceManagerStore((s) => [
|
||||
s.libraryId,
|
||||
s.category,
|
||||
s.onActionClick,
|
||||
s.selectAllState,
|
||||
s.selectedFileIds,
|
||||
]);
|
||||
const total = useFileStore((s) => s.total);
|
||||
const selectCount = getExplorerSelectedCount({
|
||||
selectAllState,
|
||||
selectedIds: selectFileIds,
|
||||
total,
|
||||
});
|
||||
const hasSelected = selectAllState === 'all' || selectCount > 0;
|
||||
|
||||
// If no libraryId, show category name or "Resource" for All
|
||||
const leftContent = isMultiSelected ? (
|
||||
const leftContent = hasSelected ? (
|
||||
<Flexbox horizontal align={'center'} gap={8} style={{ marginLeft: 0 }}>
|
||||
{libraryId ? (
|
||||
<ActionIcon
|
||||
|
|
@ -79,7 +88,12 @@ const Header = memo(() => {
|
|||
await onActionClick('delete');
|
||||
message.success(t('FileManager.actions.deleteSuccess'));
|
||||
},
|
||||
title: t('FileManager.actions.confirmDeleteMultiFiles', { count: selectCount }),
|
||||
title: t(
|
||||
selectAllState === 'all'
|
||||
? 'FileManager.actions.confirmDeleteAllFiles'
|
||||
: 'FileManager.actions.confirmDeleteMultiFiles',
|
||||
{ count: selectCount },
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import RepoIcon from '@/components/LibIcon';
|
||||
import { useKnowledgeBaseListContext } from '@/features/ResourceManager/components/KnowledgeBaseListProvider';
|
||||
import { clearTreeFolderCache } from '@/features/ResourceManager/components/LibraryHierarchy';
|
||||
import { PAGE_FILE_TYPE } from '@/features/ResourceManager/constants';
|
||||
import { useAppOrigin } from '@/hooks/useAppOrigin';
|
||||
|
|
@ -64,17 +65,11 @@ export const useFileItemDropdown = ({
|
|||
}),
|
||||
shallow,
|
||||
);
|
||||
const [removeFilesFromKnowledgeBase, addFilesToKnowledgeBase, useFetchKnowledgeBaseList] =
|
||||
useKnowledgeBaseStore((s) => [
|
||||
s.removeFilesFromKnowledgeBase,
|
||||
s.addFilesToKnowledgeBase,
|
||||
s.useFetchKnowledgeBaseList,
|
||||
]);
|
||||
|
||||
// Fetch knowledge bases - SWR caches this across all dropdown instances
|
||||
// Only the first call fetches from server, subsequent calls use cache
|
||||
// The expensive menu computation is deferred until dropdown opens (menuItems is a function)
|
||||
const { data: libraries } = useFetchKnowledgeBaseList();
|
||||
const [removeFilesFromKnowledgeBase, addFilesToKnowledgeBase] = useKnowledgeBaseStore((s) => [
|
||||
s.removeFilesFromKnowledgeBase,
|
||||
s.addFilesToKnowledgeBase,
|
||||
]);
|
||||
const libraries = useKnowledgeBaseListContext();
|
||||
|
||||
const isInLibrary = !!libraryId;
|
||||
const isFolder = fileType === 'custom/folder';
|
||||
|
|
@ -94,7 +89,7 @@ export const useFileItemDropdown = ({
|
|||
|
||||
const menuItems = useCallback(() => {
|
||||
// Filter out current knowledge base and create submenu items
|
||||
const availableKnowledgeBases = (libraries || []).filter((kb) => kb.id !== libraryId);
|
||||
const availableKnowledgeBases = libraries.filter((kb) => kb.id !== libraryId);
|
||||
|
||||
// Submenu for adding files to a library (used when NOT in a library)
|
||||
const addToKnowledgeBaseSubmenu: ItemType[] = availableKnowledgeBases.map((kb) => ({
|
||||
|
|
@ -319,7 +314,7 @@ export const useFileItemDropdown = ({
|
|||
if (libraryId) {
|
||||
await clearTreeFolderCache(libraryId);
|
||||
}
|
||||
await refreshFileList();
|
||||
await refreshFileList({ revalidateResources: false });
|
||||
|
||||
message.success(t('FileManager.actions.deleteSuccess'));
|
||||
},
|
||||
|
|
@ -330,7 +325,7 @@ export const useFileItemDropdown = ({
|
|||
).filter(Boolean);
|
||||
}, [
|
||||
addFilesToKnowledgeBase,
|
||||
clearTreeFolderCache,
|
||||
appOrigin,
|
||||
deleteResource,
|
||||
filename,
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import type { IAsyncTaskError } from '@lobechat/types';
|
||||
import { Button, Flexbox, stopPropagation } from '@lobehub/ui';
|
||||
import type { ItemType } from 'antd/es/menu/interface';
|
||||
import { isNull } from 'es-toolkit/compat';
|
||||
import { FileBoxIcon } from 'lucide-react';
|
||||
|
||||
import DropdownMenu from '../../ItemDropdown/DropdownMenu';
|
||||
import ChunksBadge from './ChunkTag';
|
||||
import { styles } from './styles';
|
||||
|
||||
interface FileListItemActionsProps {
|
||||
chunkCount?: number | null;
|
||||
chunkingError?: IAsyncTaskError | null;
|
||||
chunkingStatus?: unknown;
|
||||
embeddingError?: IAsyncTaskError | null;
|
||||
embeddingStatus?: unknown;
|
||||
finishEmbedding?: boolean;
|
||||
id: string;
|
||||
isCreatingFileParseTask: boolean;
|
||||
isFolder: boolean;
|
||||
isPage: boolean;
|
||||
isSupportedForChunking: boolean;
|
||||
menuItems: ItemType[] | (() => ItemType[]);
|
||||
parseFiles: (ids: string[]) => void;
|
||||
t: any;
|
||||
}
|
||||
|
||||
const FileListItemActions = ({
|
||||
chunkCount,
|
||||
chunkingError,
|
||||
chunkingStatus,
|
||||
embeddingError,
|
||||
embeddingStatus,
|
||||
finishEmbedding,
|
||||
id,
|
||||
isCreatingFileParseTask,
|
||||
isFolder,
|
||||
isPage,
|
||||
isSupportedForChunking,
|
||||
menuItems,
|
||||
parseFiles,
|
||||
t,
|
||||
}: FileListItemActionsProps) => (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
gap={8}
|
||||
paddingInline={8}
|
||||
onClick={stopPropagation}
|
||||
onPointerDown={stopPropagation}
|
||||
>
|
||||
{!isFolder &&
|
||||
!isPage &&
|
||||
(isCreatingFileParseTask || isNull(chunkingStatus) || !chunkingStatus ? (
|
||||
<div
|
||||
className={isCreatingFileParseTask ? undefined : styles.hover}
|
||||
title={t(
|
||||
isSupportedForChunking
|
||||
? 'FileManager.actions.chunkingTooltip'
|
||||
: 'FileManager.actions.chunkingUnsupported',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
disabled={!isSupportedForChunking}
|
||||
icon={FileBoxIcon}
|
||||
loading={isCreatingFileParseTask}
|
||||
size={'small'}
|
||||
type={'text'}
|
||||
onClick={() => {
|
||||
parseFiles([id]);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
isCreatingFileParseTask
|
||||
? 'FileManager.actions.createChunkingTask'
|
||||
: 'FileManager.actions.chunking',
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ cursor: 'default' }}>
|
||||
<ChunksBadge
|
||||
chunkCount={chunkCount}
|
||||
chunkingError={chunkingError}
|
||||
chunkingStatus={chunkingStatus as any}
|
||||
embeddingError={embeddingError}
|
||||
embeddingStatus={embeddingStatus as any}
|
||||
finishEmbedding={finishEmbedding}
|
||||
id={id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<DropdownMenu className={styles.hover} items={menuItems} />
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
export default FileListItemActions;
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { Center, Flexbox, Icon, stopPropagation } from '@lobehub/ui';
|
||||
import { Input } from 'antd';
|
||||
import { FileText, FolderIcon } from 'lucide-react';
|
||||
|
||||
import FileIcon from '@/components/FileIcon';
|
||||
|
||||
import { styles } from './styles';
|
||||
import TruncatedFileName from './TruncatedFileName';
|
||||
|
||||
interface FileListItemNameProps {
|
||||
emoji?: string | null;
|
||||
fallbackName: string;
|
||||
fileType: string;
|
||||
inputRef: any;
|
||||
isFolder: boolean;
|
||||
isPage: boolean;
|
||||
isRenaming: boolean;
|
||||
name: string;
|
||||
onRenameCancel: () => void;
|
||||
onRenameConfirm: () => void;
|
||||
onRenamingValueChange: (value: string) => void;
|
||||
renamingValue: string;
|
||||
}
|
||||
|
||||
const FileListItemName = ({
|
||||
emoji,
|
||||
fallbackName,
|
||||
fileType,
|
||||
inputRef,
|
||||
isFolder,
|
||||
isPage,
|
||||
isRenaming,
|
||||
name,
|
||||
onRenameCancel,
|
||||
onRenameConfirm,
|
||||
onRenamingValueChange,
|
||||
renamingValue,
|
||||
}: FileListItemNameProps) => (
|
||||
<Flexbox horizontal align={'center'} className={styles.nameContainer}>
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
justify={'center'}
|
||||
style={{ fontSize: 24, marginInline: 8, width: 24 }}
|
||||
>
|
||||
{isFolder ? (
|
||||
<Icon icon={FolderIcon} size={24} />
|
||||
) : isPage ? (
|
||||
emoji ? (
|
||||
<span style={{ fontSize: 24 }}>{emoji}</span>
|
||||
) : (
|
||||
<Center height={24} width={24}>
|
||||
<Icon icon={FileText} size={24} />
|
||||
</Center>
|
||||
)
|
||||
) : (
|
||||
<FileIcon fileName={name} fileType={fileType} size={24} />
|
||||
)}
|
||||
</Flexbox>
|
||||
{isRenaming && isFolder ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="small"
|
||||
style={{ flex: 1, maxWidth: 400 }}
|
||||
value={renamingValue}
|
||||
onBlur={onRenameConfirm}
|
||||
onChange={(e) => onRenamingValueChange(e.target.value)}
|
||||
onClick={stopPropagation}
|
||||
onPointerDown={stopPropagation}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onRenameConfirm();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onRenameCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TruncatedFileName className={styles.name} name={name || fallbackName} />
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
export default FileListItemName;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export const FILE_DATE_WIDTH = 160;
|
||||
export const FILE_SIZE_WIDTH = 140;
|
||||
|
|
@ -1,127 +1,27 @@
|
|||
import {
|
||||
Button,
|
||||
Center,
|
||||
Checkbox,
|
||||
ContextMenuTrigger,
|
||||
Flexbox,
|
||||
Icon,
|
||||
stopPropagation,
|
||||
} from '@lobehub/ui';
|
||||
import { App, Input } from 'antd';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import dayjs from 'dayjs';
|
||||
import { isNull } from 'es-toolkit/compat';
|
||||
import { FileBoxIcon, FileText, FolderIcon } from 'lucide-react';
|
||||
import { type DragEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Center, Checkbox, ContextMenuTrigger, Flexbox } from '@lobehub/ui';
|
||||
import { cssVar, cx } from 'antd-style';
|
||||
import { isEqual } from 'es-toolkit';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import FileIcon from '@/components/FileIcon';
|
||||
import { clearTreeFolderCache } from '@/features/ResourceManager/components/LibraryHierarchy';
|
||||
import { PAGE_FILE_TYPE } from '@/features/ResourceManager/constants';
|
||||
import {
|
||||
getTransparentDragImage,
|
||||
useDragActive,
|
||||
useDragState,
|
||||
} from '@/routes/(main)/resource/features/DndContextWrapper';
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { isExplorerItemSelected } from '@/routes/(main)/resource/features/store/selectors';
|
||||
import { fileManagerSelectors, useFileStore } from '@/store/file';
|
||||
import { type FileListItem as FileListItemType } from '@/types/files';
|
||||
import type { FileListItem as FileListItemType } from '@/types/files';
|
||||
import { formatSize } from '@/utils/format';
|
||||
import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported';
|
||||
|
||||
import { useFileItemClick } from '../../hooks/useFileItemClick';
|
||||
import DropdownMenu from '../../ItemDropdown/DropdownMenu';
|
||||
import { useFileItemDropdown } from '../../ItemDropdown/useFileItemDropdown';
|
||||
import ChunksBadge from './ChunkTag';
|
||||
import TruncatedFileName from './TruncatedFileName';
|
||||
import { FILE_DATE_WIDTH, FILE_SIZE_WIDTH } from './constants';
|
||||
import FileListItemActions from './FileListItemActions';
|
||||
import FileListItemName from './FileListItemName';
|
||||
import { styles } from './styles';
|
||||
import { useFileListItemDrag } from './useFileListItemDrag';
|
||||
import { useFileListItemMeta } from './useFileListItemMeta';
|
||||
import { useFileListItemRename } from './useFileListItemRename';
|
||||
|
||||
export const FILE_DATE_WIDTH = 160;
|
||||
export const FILE_SIZE_WIDTH = 140;
|
||||
|
||||
const styles = createStaticStyles(({ css }) => {
|
||||
return {
|
||||
container: css`
|
||||
cursor: pointer;
|
||||
min-width: 800px;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
|
||||
dragOver: css`
|
||||
outline: 1px dashed ${cssVar.colorPrimaryBorder};
|
||||
outline-offset: -2px;
|
||||
|
||||
&,
|
||||
&:hover {
|
||||
background: ${cssVar.colorPrimaryBg};
|
||||
}
|
||||
`,
|
||||
|
||||
dragging: css`
|
||||
will-change: transform;
|
||||
opacity: 0.5;
|
||||
`,
|
||||
|
||||
evenRow: css`
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
|
||||
/* Hover effect overrides zebra striping on the hovered row only */
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
|
||||
/* Hide zebra striping when any row is hovered */
|
||||
.any-row-hovered & {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* But keep hover effect on the actual hovered row */
|
||||
.any-row-hovered &:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
|
||||
hover: css`
|
||||
opacity: 0;
|
||||
|
||||
&[data-popup-open],
|
||||
.file-list-item-group:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
`,
|
||||
item: css`
|
||||
padding-block: 0;
|
||||
padding-inline: 0 24px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
name: css`
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
min-width: 0;
|
||||
margin-inline-start: 12px;
|
||||
|
||||
color: ${cssVar.colorText};
|
||||
white-space: nowrap;
|
||||
`,
|
||||
nameContainer: css`
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`,
|
||||
selected: css`
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
export { FILE_DATE_WIDTH, FILE_SIZE_WIDTH };
|
||||
|
||||
interface FileListItemProps extends FileListItemType {
|
||||
columnWidths: {
|
||||
|
|
@ -130,453 +30,226 @@ interface FileListItemProps extends FileListItemType {
|
|||
size: number;
|
||||
};
|
||||
index: number;
|
||||
isAnyRowHovered: boolean;
|
||||
onHoverChange: (isHovered: boolean) => void;
|
||||
onSelectedChange: (id: string, selected: boolean, shiftKey: boolean, index: number) => void;
|
||||
pendingRenameItemId?: string | null;
|
||||
selected?: boolean;
|
||||
slug?: string | null;
|
||||
}
|
||||
|
||||
const FileListItem = memo<FileListItemProps>(
|
||||
({
|
||||
size,
|
||||
chunkingError,
|
||||
columnWidths,
|
||||
embeddingError,
|
||||
embeddingStatus,
|
||||
finishEmbedding,
|
||||
chunkCount,
|
||||
url,
|
||||
const FileListItem = ({
|
||||
chunkCount,
|
||||
chunkingError,
|
||||
chunkingStatus,
|
||||
columnWidths,
|
||||
createdAt,
|
||||
embeddingError,
|
||||
embeddingStatus,
|
||||
fileType,
|
||||
finishEmbedding,
|
||||
id,
|
||||
index,
|
||||
metadata,
|
||||
name,
|
||||
onSelectedChange,
|
||||
selected,
|
||||
size,
|
||||
slug,
|
||||
sourceType,
|
||||
url,
|
||||
}: FileListItemProps) => {
|
||||
const { t } = useTranslation(['components', 'file']);
|
||||
const fileStoreState = useFileStore(
|
||||
(s) => ({
|
||||
isCreatingFileParseTask: fileManagerSelectors.isCreatingFileParseTask(id)(s),
|
||||
parseFiles: s.parseFilesToChunks,
|
||||
refreshFileList: s.refreshFileList,
|
||||
updateResource: s.updateResource,
|
||||
}),
|
||||
isEqual,
|
||||
);
|
||||
const resourceManagerState = useResourceManagerStore(
|
||||
(s) => ({
|
||||
isPendingRename: s.pendingRenameItemId === id,
|
||||
libraryId: s.libraryId,
|
||||
selected: isExplorerItemSelected({
|
||||
id,
|
||||
selectAllState: s.selectAllState,
|
||||
selectedIds: s.selectedFileIds,
|
||||
}),
|
||||
setPendingRenameItemId: s.setPendingRenameItemId,
|
||||
}),
|
||||
shallow,
|
||||
);
|
||||
const isSelected = selected ?? resourceManagerState.selected;
|
||||
|
||||
const { displayTime, emoji, isFolder, isPage, isSupportedForChunking } = useFileListItemMeta({
|
||||
createdAt,
|
||||
fileType,
|
||||
metadata,
|
||||
name,
|
||||
sourceType,
|
||||
});
|
||||
const {
|
||||
handleDragEnd,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDragStart,
|
||||
handleDrop,
|
||||
isDragging,
|
||||
isOver,
|
||||
} = useFileListItemDrag({
|
||||
fileType,
|
||||
id,
|
||||
createdAt,
|
||||
selected,
|
||||
chunkingStatus,
|
||||
onSelectedChange,
|
||||
index,
|
||||
metadata,
|
||||
isFolder,
|
||||
libraryId: resourceManagerState.libraryId,
|
||||
name,
|
||||
sourceType,
|
||||
});
|
||||
const {
|
||||
handleRenameCancel,
|
||||
handleRenameConfirm,
|
||||
handleRenameStart,
|
||||
inputRef,
|
||||
isRenaming,
|
||||
renamingValue,
|
||||
setRenamingValue,
|
||||
} = useFileListItemRename({
|
||||
id,
|
||||
isPendingRename: resourceManagerState.isPendingRename,
|
||||
isFolder,
|
||||
libraryId: resourceManagerState.libraryId,
|
||||
name,
|
||||
refreshFileList: fileStoreState.refreshFileList,
|
||||
setPendingRenameItemId: resourceManagerState.setPendingRenameItemId,
|
||||
updateResource: fileStoreState.updateResource,
|
||||
});
|
||||
|
||||
const handleItemClick = useFileItemClick({
|
||||
id,
|
||||
isFolder,
|
||||
isPage,
|
||||
libraryId: resourceManagerState.libraryId,
|
||||
slug,
|
||||
pendingRenameItemId,
|
||||
onHoverChange,
|
||||
}) => {
|
||||
const { t } = useTranslation(['components', 'file']);
|
||||
const { message } = App.useApp();
|
||||
// Consolidate all FileStore subscriptions with shallow equality
|
||||
const fileStoreState = useFileStore(
|
||||
(s) => ({
|
||||
isCreatingFileParseTask: fileManagerSelectors.isCreatingFileParseTask(id)(s),
|
||||
parseFiles: s.parseFilesToChunks,
|
||||
refreshFileList: s.refreshFileList,
|
||||
updateResource: s.updateResource,
|
||||
}),
|
||||
shallow,
|
||||
);
|
||||
});
|
||||
const { menuItems } = useFileItemDropdown({
|
||||
fileType,
|
||||
filename: name,
|
||||
id,
|
||||
libraryId: resourceManagerState.libraryId,
|
||||
onRenameStart: isFolder ? handleRenameStart : undefined,
|
||||
sourceType,
|
||||
url,
|
||||
});
|
||||
|
||||
// Consolidate all ResourceManagerStore subscriptions with shallow equality
|
||||
const resourceManagerState = useResourceManagerStore(
|
||||
(s) => ({
|
||||
libraryId: s.libraryId,
|
||||
setPendingRenameItemId: s.setPendingRenameItemId,
|
||||
}),
|
||||
shallow,
|
||||
);
|
||||
const handleCheckboxClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onSelectedChange(id, !isSelected, e.shiftKey, index);
|
||||
},
|
||||
[id, index, isSelected, onSelectedChange],
|
||||
);
|
||||
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renamingValue, setRenamingValue] = useState(name);
|
||||
const inputRef = useRef<any>(null);
|
||||
const isConfirmingRef = useRef(false);
|
||||
const isDragActive = useDragActive();
|
||||
const { setCurrentDrag } = useDragState();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const handleCheckboxPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const computedValues = useMemo(() => {
|
||||
const lowerFileType = fileType?.toLowerCase();
|
||||
const lowerName = name?.toLowerCase();
|
||||
const isPDF = lowerFileType === 'pdf' || lowerName?.endsWith('.pdf');
|
||||
// Office files should use the MSDoc viewer, not the page editor
|
||||
const isOfficeFile =
|
||||
lowerName?.endsWith('.xls') ||
|
||||
lowerName?.endsWith('.xlsx') ||
|
||||
lowerName?.endsWith('.doc') ||
|
||||
lowerName?.endsWith('.docx') ||
|
||||
lowerName?.endsWith('.ppt') ||
|
||||
lowerName?.endsWith('.pptx') ||
|
||||
lowerName?.endsWith('.odt');
|
||||
return {
|
||||
emoji: sourceType === 'document' || fileType === PAGE_FILE_TYPE ? metadata?.emoji : null,
|
||||
isFolder: fileType === 'custom/folder',
|
||||
// PDF and Office files should not be treated as pages, even if they have sourceType='document'
|
||||
isPage:
|
||||
!isPDF && !isOfficeFile && (sourceType === 'document' || fileType === PAGE_FILE_TYPE),
|
||||
isSupportedForChunking: !isChunkingUnsupported(fileType),
|
||||
};
|
||||
}, [fileType, sourceType, metadata?.emoji, name]);
|
||||
|
||||
const { isSupportedForChunking, isPage, isFolder, emoji } = computedValues;
|
||||
|
||||
const dragData = useMemo(
|
||||
() => ({
|
||||
fileType,
|
||||
isFolder,
|
||||
name,
|
||||
sourceType,
|
||||
}),
|
||||
[fileType, isFolder, name, sourceType],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!resourceManagerState.libraryId) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
setCurrentDrag({
|
||||
data: dragData,
|
||||
id,
|
||||
type: isFolder ? 'folder' : 'file',
|
||||
});
|
||||
|
||||
// Set drag image to be transparent (we use custom overlay)
|
||||
const img = getTransparentDragImage();
|
||||
if (img) {
|
||||
e.dataTransfer.setDragImage(img, 0, 0);
|
||||
}
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
},
|
||||
[resourceManagerState.libraryId, dragData, id, isFolder, setCurrentDrag],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!isFolder || !isDragActive) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOver(true);
|
||||
},
|
||||
[isFolder, isDragActive],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(() => {
|
||||
setIsOver(false);
|
||||
}, []);
|
||||
|
||||
// Memoize display time calculation
|
||||
const displayTime = useMemo(
|
||||
() =>
|
||||
dayjs().diff(dayjs(createdAt), 'd') < 7
|
||||
? dayjs(createdAt).fromNow()
|
||||
: dayjs(createdAt).format('YYYY-MM-DD'),
|
||||
[createdAt],
|
||||
);
|
||||
|
||||
const handleRenameStart = useCallback(() => {
|
||||
setIsRenaming(true);
|
||||
setRenamingValue(name);
|
||||
// Focus input after render
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
}, [name]);
|
||||
|
||||
const handleRenameConfirm = useCallback(async () => {
|
||||
// Prevent duplicate calls (e.g., from both Enter key and onBlur)
|
||||
if (isConfirmingRef.current) return;
|
||||
isConfirmingRef.current = true;
|
||||
|
||||
if (!renamingValue.trim()) {
|
||||
message.error(t('FileManager.actions.renameError'));
|
||||
isConfirmingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (renamingValue.trim() === name) {
|
||||
setIsRenaming(false);
|
||||
isConfirmingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use optimistic updateResource for instant UI update
|
||||
await fileStoreState.updateResource(id, { name: renamingValue.trim() });
|
||||
if (resourceManagerState.libraryId) {
|
||||
await clearTreeFolderCache(resourceManagerState.libraryId);
|
||||
}
|
||||
await fileStoreState.refreshFileList();
|
||||
|
||||
message.success(t('FileManager.actions.renameSuccess'));
|
||||
setIsRenaming(false);
|
||||
} catch (error) {
|
||||
console.error('Rename error:', error);
|
||||
message.error(t('FileManager.actions.renameError'));
|
||||
} finally {
|
||||
isConfirmingRef.current = false;
|
||||
}
|
||||
}, [
|
||||
fileStoreState.refreshFileList,
|
||||
fileStoreState.updateResource,
|
||||
id,
|
||||
message,
|
||||
name,
|
||||
renamingValue,
|
||||
resourceManagerState.libraryId,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleRenameCancel = useCallback(() => {
|
||||
// Don't cancel if we're in the middle of confirming
|
||||
if (isConfirmingRef.current) return;
|
||||
setIsRenaming(false);
|
||||
setRenamingValue(name);
|
||||
}, [name]);
|
||||
|
||||
// Use shared click handler hook
|
||||
const handleItemClick = useFileItemClick({
|
||||
id,
|
||||
isFolder,
|
||||
isPage,
|
||||
libraryId: resourceManagerState.libraryId,
|
||||
slug,
|
||||
});
|
||||
|
||||
// Auto-start renaming if this is the pending rename item
|
||||
useEffect(() => {
|
||||
if (pendingRenameItemId === id && isFolder && !isRenaming) {
|
||||
handleRenameStart();
|
||||
resourceManagerState.setPendingRenameItemId(null);
|
||||
}
|
||||
}, [pendingRenameItemId, id, isFolder, resourceManagerState]);
|
||||
|
||||
const { menuItems } = useFileItemDropdown({
|
||||
fileType,
|
||||
filename: name,
|
||||
id,
|
||||
libraryId: resourceManagerState.libraryId,
|
||||
onRenameStart: isFolder ? handleRenameStart : undefined,
|
||||
sourceType,
|
||||
url,
|
||||
});
|
||||
|
||||
return (
|
||||
<ContextMenuTrigger items={menuItems}>
|
||||
return (
|
||||
<ContextMenuTrigger items={menuItems}>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
data-drop-target-id={id}
|
||||
data-is-folder={String(isFolder)}
|
||||
data-row-index={index}
|
||||
draggable={!!resourceManagerState.libraryId}
|
||||
height={48}
|
||||
paddingInline={8}
|
||||
className={cx(
|
||||
styles.container,
|
||||
'file-list-item-group',
|
||||
index % 2 === 0 && styles.evenRow,
|
||||
isSelected && styles.selected,
|
||||
isDragging && styles.dragging,
|
||||
isOver && styles.dragOver,
|
||||
)}
|
||||
style={{
|
||||
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={handleItemClick}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Center
|
||||
height={40}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={handleCheckboxClick}
|
||||
onPointerDown={handleCheckboxPointerDown}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
</Center>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
data-drop-target-id={id}
|
||||
data-is-folder={String(isFolder)}
|
||||
data-row-index={index}
|
||||
draggable={!!resourceManagerState.libraryId}
|
||||
height={48}
|
||||
paddingInline={8}
|
||||
className={cx(
|
||||
styles.container,
|
||||
'file-list-item-group',
|
||||
index % 2 === 0 && styles.evenRow,
|
||||
selected && styles.selected,
|
||||
isDragging && styles.dragging,
|
||||
isOver && styles.dragOver,
|
||||
)}
|
||||
className={styles.item}
|
||||
distribution={'space-between'}
|
||||
style={{
|
||||
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
maxWidth: columnWidths.name,
|
||||
minWidth: columnWidths.name,
|
||||
paddingInline: 8,
|
||||
width: columnWidths.name,
|
||||
}}
|
||||
onClick={handleItemClick}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
onDrop={handleDrop}
|
||||
onMouseEnter={() => onHoverChange(true)}
|
||||
onMouseLeave={() => onHoverChange(false)}
|
||||
>
|
||||
<Center
|
||||
height={40}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onSelectedChange(id, !selected, e.shiftKey, index);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
// Prevent text selection when shift-clicking for batch selection
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selected} />
|
||||
</Center>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.item}
|
||||
distribution={'space-between'}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
maxWidth: columnWidths.name,
|
||||
minWidth: columnWidths.name,
|
||||
paddingInline: 8,
|
||||
width: columnWidths.name,
|
||||
}}
|
||||
>
|
||||
<Flexbox horizontal align={'center'} className={styles.nameContainer}>
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
justify={'center'}
|
||||
style={{ fontSize: 24, marginInline: 8, width: 24 }}
|
||||
>
|
||||
{isFolder ? (
|
||||
<Icon icon={FolderIcon} size={24} />
|
||||
) : isPage ? (
|
||||
emoji ? (
|
||||
<span style={{ fontSize: 24 }}>{emoji}</span>
|
||||
) : (
|
||||
<Center height={24} width={24}>
|
||||
<Icon icon={FileText} size={24} />
|
||||
</Center>
|
||||
)
|
||||
) : (
|
||||
<FileIcon fileName={name} fileType={fileType} size={24} />
|
||||
)}
|
||||
</Flexbox>
|
||||
{isRenaming && isFolder ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="small"
|
||||
style={{ flex: 1, maxWidth: 400 }}
|
||||
value={renamingValue}
|
||||
onBlur={handleRenameConfirm}
|
||||
onChange={(e) => setRenamingValue(e.target.value)}
|
||||
onClick={stopPropagation}
|
||||
onPointerDown={stopPropagation}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleRenameConfirm();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleRenameCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TruncatedFileName
|
||||
className={styles.name}
|
||||
name={name || t('file:pageList.untitled')}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
gap={8}
|
||||
paddingInline={8}
|
||||
onClick={stopPropagation}
|
||||
onPointerDown={stopPropagation}
|
||||
>
|
||||
{!isFolder &&
|
||||
!isPage &&
|
||||
(fileStoreState.isCreatingFileParseTask ||
|
||||
isNull(chunkingStatus) ||
|
||||
!chunkingStatus ? (
|
||||
<div
|
||||
className={fileStoreState.isCreatingFileParseTask ? undefined : styles.hover}
|
||||
title={t(
|
||||
isSupportedForChunking
|
||||
? 'FileManager.actions.chunkingTooltip'
|
||||
: 'FileManager.actions.chunkingUnsupported',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
disabled={!isSupportedForChunking}
|
||||
icon={FileBoxIcon}
|
||||
loading={fileStoreState.isCreatingFileParseTask}
|
||||
size={'small'}
|
||||
type={'text'}
|
||||
onClick={() => {
|
||||
fileStoreState.parseFiles([id]);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
fileStoreState.isCreatingFileParseTask
|
||||
? 'FileManager.actions.createChunkingTask'
|
||||
: 'FileManager.actions.chunking',
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ cursor: 'default' }}>
|
||||
<ChunksBadge
|
||||
chunkCount={chunkCount}
|
||||
chunkingError={chunkingError}
|
||||
chunkingStatus={chunkingStatus}
|
||||
embeddingError={embeddingError}
|
||||
embeddingStatus={embeddingStatus}
|
||||
finishEmbedding={finishEmbedding}
|
||||
id={id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<DropdownMenu className={styles.hover} items={menuItems} />
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
{!isDragging && (
|
||||
<>
|
||||
<Flexbox className={styles.item} style={{ flexShrink: 0 }} width={columnWidths.date}>
|
||||
{displayTime}
|
||||
</Flexbox>
|
||||
<Flexbox className={styles.item} style={{ flexShrink: 0 }} width={columnWidths.size}>
|
||||
{isFolder || isPage ? '-' : formatSize(size)}
|
||||
</Flexbox>
|
||||
</>
|
||||
)}
|
||||
<FileListItemName
|
||||
emoji={emoji}
|
||||
fallbackName={t('file:pageList.untitled')}
|
||||
fileType={fileType}
|
||||
inputRef={inputRef}
|
||||
isFolder={isFolder}
|
||||
isPage={isPage}
|
||||
isRenaming={isRenaming}
|
||||
name={name}
|
||||
renamingValue={renamingValue}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
onRenameConfirm={handleRenameConfirm}
|
||||
onRenamingValueChange={setRenamingValue}
|
||||
/>
|
||||
<FileListItemActions
|
||||
chunkCount={chunkCount}
|
||||
chunkingError={chunkingError}
|
||||
chunkingStatus={chunkingStatus}
|
||||
embeddingError={embeddingError}
|
||||
embeddingStatus={embeddingStatus}
|
||||
finishEmbedding={finishEmbedding}
|
||||
id={id}
|
||||
isCreatingFileParseTask={fileStoreState.isCreatingFileParseTask}
|
||||
isFolder={isFolder}
|
||||
isPage={isPage}
|
||||
isSupportedForChunking={isSupportedForChunking}
|
||||
menuItems={menuItems}
|
||||
parseFiles={fileStoreState.parseFiles}
|
||||
t={t}
|
||||
/>
|
||||
</Flexbox>
|
||||
</ContextMenuTrigger>
|
||||
);
|
||||
},
|
||||
// Custom comparison function to prevent unnecessary re-renders
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.name === nextProps.name &&
|
||||
prevProps.selected === nextProps.selected &&
|
||||
prevProps.chunkingStatus === nextProps.chunkingStatus &&
|
||||
prevProps.embeddingStatus === nextProps.embeddingStatus &&
|
||||
prevProps.chunkCount === nextProps.chunkCount &&
|
||||
prevProps.chunkingError === nextProps.chunkingError &&
|
||||
prevProps.embeddingError === nextProps.embeddingError &&
|
||||
prevProps.finishEmbedding === nextProps.finishEmbedding &&
|
||||
prevProps.pendingRenameItemId === nextProps.pendingRenameItemId &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.createdAt === nextProps.createdAt &&
|
||||
prevProps.fileType === nextProps.fileType &&
|
||||
prevProps.sourceType === nextProps.sourceType &&
|
||||
prevProps.slug === nextProps.slug &&
|
||||
prevProps.url === nextProps.url &&
|
||||
prevProps.columnWidths.name === nextProps.columnWidths.name &&
|
||||
prevProps.columnWidths.date === nextProps.columnWidths.date &&
|
||||
prevProps.columnWidths.size === nextProps.columnWidths.size &&
|
||||
prevProps.isAnyRowHovered === nextProps.isAnyRowHovered
|
||||
);
|
||||
},
|
||||
);
|
||||
{!isDragging && (
|
||||
<>
|
||||
<Flexbox className={styles.item} style={{ flexShrink: 0 }} width={columnWidths.date}>
|
||||
{displayTime}
|
||||
</Flexbox>
|
||||
<Flexbox className={styles.item} style={{ flexShrink: 0 }} width={columnWidths.size}>
|
||||
{isFolder || isPage ? '-' : formatSize(size)}
|
||||
</Flexbox>
|
||||
</>
|
||||
)}
|
||||
</Flexbox>
|
||||
</ContextMenuTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
FileListItem.displayName = 'FileListItem';
|
||||
|
||||
export default FileListItem;
|
||||
export default memo(FileListItem);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
|
||||
export const styles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
min-width: 800px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
|
||||
background-color: ${cssVar.colorFillTertiary};
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity ${cssVar.motionDurationMid} ${cssVar.motionEaseInOut},
|
||||
background-color ${cssVar.motionDurationMid} ${cssVar.motionEaseInOut};
|
||||
}
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
dragOver: css`
|
||||
outline: 1px dashed ${cssVar.colorPrimaryBorder};
|
||||
outline-offset: -2px;
|
||||
|
||||
&,
|
||||
&:hover {
|
||||
&::after {
|
||||
background-color: ${cssVar.colorPrimaryBg};
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
opacity: 0;
|
||||
}
|
||||
`,
|
||||
|
||||
dragging: css`
|
||||
will-change: transform;
|
||||
opacity: 0.5;
|
||||
`,
|
||||
|
||||
evenRow: css`
|
||||
&::before {
|
||||
content: '';
|
||||
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
|
||||
background-color: ${cssVar.colorFillQuaternary};
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 1;
|
||||
transition: opacity 300ms ${cssVar.motionEaseInOut};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::before {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.list-view-drop-zone:hover & {
|
||||
&::before {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
hover: css`
|
||||
opacity: 0;
|
||||
|
||||
&[data-popup-open],
|
||||
.file-list-item-group:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
`,
|
||||
|
||||
item: css`
|
||||
padding-block: 0;
|
||||
padding-inline: 0 24px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
|
||||
name: css`
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
min-width: 0;
|
||||
margin-inline-start: 12px;
|
||||
|
||||
color: ${cssVar.colorText};
|
||||
white-space: nowrap;
|
||||
`,
|
||||
|
||||
nameContainer: css`
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`,
|
||||
|
||||
selected: css`
|
||||
&::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background-color: ${cssVar.colorFillTertiary};
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::after {
|
||||
background-color: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import type { DragEvent } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getTransparentDragImage,
|
||||
useDragActive,
|
||||
useSetCurrentDrag,
|
||||
} from '@/routes/(main)/resource/features/DndContextWrapper';
|
||||
|
||||
interface UseFileListItemDragOptions {
|
||||
fileType?: string | null;
|
||||
id: string;
|
||||
isFolder: boolean;
|
||||
libraryId?: string;
|
||||
name?: string | null;
|
||||
sourceType?: string | null;
|
||||
}
|
||||
|
||||
export const useFileListItemDrag = ({
|
||||
fileType,
|
||||
id,
|
||||
isFolder,
|
||||
libraryId,
|
||||
name,
|
||||
sourceType,
|
||||
}: UseFileListItemDragOptions) => {
|
||||
const isDragActive = useDragActive();
|
||||
const setCurrentDrag = useSetCurrentDrag();
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
|
||||
const dragData = useMemo(
|
||||
() => ({
|
||||
fileType,
|
||||
isFolder,
|
||||
name,
|
||||
sourceType,
|
||||
}),
|
||||
[fileType, isFolder, name, sourceType],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!libraryId) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
setCurrentDrag({
|
||||
data: dragData,
|
||||
id,
|
||||
type: isFolder ? 'folder' : 'file',
|
||||
});
|
||||
|
||||
const img = getTransparentDragImage();
|
||||
if (img) {
|
||||
e.dataTransfer.setDragImage(img, 0, 0);
|
||||
}
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
},
|
||||
[dragData, id, isFolder, libraryId, setCurrentDrag],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!isFolder || !isDragActive) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOver(true);
|
||||
},
|
||||
[isDragActive, isFolder],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(() => {
|
||||
setIsOver(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleDragEnd,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDragStart,
|
||||
handleDrop,
|
||||
isDragging,
|
||||
isOver,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import dayjs from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PAGE_FILE_TYPE } from '@/features/ResourceManager/constants';
|
||||
import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported';
|
||||
|
||||
interface UseFileListItemMetaOptions {
|
||||
createdAt: Date;
|
||||
fileType: string;
|
||||
metadata?: {
|
||||
emoji?: string | null;
|
||||
} | null;
|
||||
name?: string | null;
|
||||
sourceType?: string | null;
|
||||
}
|
||||
|
||||
export const useFileListItemMeta = ({
|
||||
createdAt,
|
||||
fileType,
|
||||
metadata,
|
||||
name,
|
||||
sourceType,
|
||||
}: UseFileListItemMetaOptions) =>
|
||||
useMemo(() => {
|
||||
const lowerFileType = fileType?.toLowerCase();
|
||||
const lowerName = name?.toLowerCase();
|
||||
const isPDF = lowerFileType === 'pdf' || lowerName?.endsWith('.pdf');
|
||||
const isOfficeFile =
|
||||
lowerName?.endsWith('.xls') ||
|
||||
lowerName?.endsWith('.xlsx') ||
|
||||
lowerName?.endsWith('.doc') ||
|
||||
lowerName?.endsWith('.docx') ||
|
||||
lowerName?.endsWith('.ppt') ||
|
||||
lowerName?.endsWith('.pptx') ||
|
||||
lowerName?.endsWith('.odt');
|
||||
|
||||
return {
|
||||
displayTime:
|
||||
dayjs().diff(dayjs(createdAt), 'd') < 7
|
||||
? dayjs(createdAt).fromNow()
|
||||
: dayjs(createdAt).format('YYYY-MM-DD'),
|
||||
emoji: sourceType === 'document' || fileType === PAGE_FILE_TYPE ? metadata?.emoji : null,
|
||||
isFolder: fileType === 'custom/folder',
|
||||
isPage: !isPDF && !isOfficeFile && (sourceType === 'document' || fileType === PAGE_FILE_TYPE),
|
||||
isSupportedForChunking: !isChunkingUnsupported(fileType),
|
||||
};
|
||||
}, [createdAt, fileType, metadata?.emoji, name, sourceType]);
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { App } from 'antd';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { clearTreeFolderCache } from '@/features/ResourceManager/components/LibraryHierarchy';
|
||||
import { useEventCallback } from '@/hooks/useEventCallback';
|
||||
|
||||
interface UseFileListItemRenameOptions {
|
||||
id: string;
|
||||
isFolder: boolean;
|
||||
isPendingRename?: boolean;
|
||||
libraryId?: string;
|
||||
name?: string | null;
|
||||
refreshFileList: (options?: { revalidateResources?: boolean }) => Promise<void>;
|
||||
setPendingRenameItemId: (id: string | null) => void;
|
||||
updateResource: (id: string, payload: { name: string }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export const useFileListItemRename = ({
|
||||
id,
|
||||
isPendingRename,
|
||||
isFolder,
|
||||
libraryId,
|
||||
name,
|
||||
refreshFileList,
|
||||
setPendingRenameItemId,
|
||||
updateResource,
|
||||
}: UseFileListItemRenameOptions) => {
|
||||
const { t } = useTranslation(['components', 'file']);
|
||||
const { message } = App.useApp();
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renamingValue, setRenamingValue] = useState(name || '');
|
||||
const inputRef = useRef<any>(null);
|
||||
const isConfirmingRef = useRef(false);
|
||||
|
||||
const handleRenameStart = useCallback(() => {
|
||||
setIsRenaming(true);
|
||||
setRenamingValue(name || '');
|
||||
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
}, [name]);
|
||||
|
||||
const handleRenameConfirm = useEventCallback(async () => {
|
||||
if (isConfirmingRef.current) return;
|
||||
isConfirmingRef.current = true;
|
||||
|
||||
if (!renamingValue.trim()) {
|
||||
message.error(t('FileManager.actions.renameError'));
|
||||
isConfirmingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (renamingValue.trim() === name) {
|
||||
setIsRenaming(false);
|
||||
isConfirmingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateResource(id, { name: renamingValue.trim() });
|
||||
if (libraryId) {
|
||||
await clearTreeFolderCache(libraryId);
|
||||
}
|
||||
await refreshFileList({ revalidateResources: false });
|
||||
|
||||
message.success(t('FileManager.actions.renameSuccess'));
|
||||
setIsRenaming(false);
|
||||
} catch (error) {
|
||||
console.error('Rename error:', error);
|
||||
message.error(t('FileManager.actions.renameError'));
|
||||
} finally {
|
||||
isConfirmingRef.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
const handleRenameCancel = useCallback(() => {
|
||||
if (isConfirmingRef.current) return;
|
||||
setIsRenaming(false);
|
||||
setRenamingValue(name || '');
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPendingRename && isFolder && !isRenaming) {
|
||||
handleRenameStart();
|
||||
setPendingRenameItemId(null);
|
||||
}
|
||||
}, [handleRenameStart, isFolder, isPendingRename, isRenaming, setPendingRenameItemId]);
|
||||
|
||||
return {
|
||||
handleRenameCancel,
|
||||
handleRenameConfirm,
|
||||
handleRenameStart,
|
||||
inputRef,
|
||||
isRenaming,
|
||||
renamingValue,
|
||||
setRenamingValue,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
import { styles } from './styles';
|
||||
import { useExplorerDropZone } from './useExplorerDropZone';
|
||||
|
||||
interface ListViewDropZoneProps {
|
||||
children: ReactNode;
|
||||
currentFolderId: string | null;
|
||||
virtuosoRef: RefObject<VirtuosoHandle | null>;
|
||||
}
|
||||
|
||||
const localStyles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`,
|
||||
}));
|
||||
|
||||
const ListViewDropZone = ({ children, currentFolderId, virtuosoRef }: ListViewDropZoneProps) => {
|
||||
const { containerRef, handleDragLeave, handleDragOver, handleDrop, isDropZoneActive } =
|
||||
useExplorerDropZone(virtuosoRef);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-drop-target-id={currentFolderId || undefined}
|
||||
data-is-folder="true"
|
||||
ref={containerRef}
|
||||
className={cx(
|
||||
localStyles.container,
|
||||
'list-view-drop-zone',
|
||||
styles.dropZone,
|
||||
isDropZoneActive && styles.dropZoneActive,
|
||||
)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListViewDropZone;
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { Center, Checkbox, Flexbox } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import type { FileListItem } from '@/types/files';
|
||||
|
||||
import {
|
||||
useExplorerSelectionActions,
|
||||
useExplorerSelectionSummary,
|
||||
} from '../hooks/useExplorerSelection';
|
||||
import ColumnResizeHandle from './ColumnResizeHandle';
|
||||
import ListViewSelectAllHint from './ListViewSelectAllHint';
|
||||
import { styles } from './styles';
|
||||
|
||||
interface ListViewHeaderProps {
|
||||
columnWidths: {
|
||||
date: number;
|
||||
name: number;
|
||||
size: number;
|
||||
};
|
||||
data: FileListItem[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
const ListViewHeader = ({ columnWidths, data, hasMore }: ListViewHeaderProps) => {
|
||||
const { t } = useTranslation(['components', 'file']);
|
||||
const updateColumnWidth = useGlobalStore((s) => s.updateResourceManagerColumnWidth);
|
||||
const { handleSelectAll, handleSelectAllResources } = useExplorerSelectionActions(data);
|
||||
const {
|
||||
allSelected,
|
||||
indeterminate,
|
||||
selectAllState,
|
||||
selectedCount,
|
||||
showSelectAllHint,
|
||||
total,
|
||||
} = useExplorerSelectionSummary({
|
||||
data,
|
||||
hasMore,
|
||||
});
|
||||
const isAllResultsSelected = selectAllState === 'all' && total === selectedCount;
|
||||
const selectedLabelKey =
|
||||
selectAllState === 'all'
|
||||
? total
|
||||
? isAllResultsSelected
|
||||
? 'FileManager.total.allSelectedCount'
|
||||
: 'FileManager.total.selectedCount'
|
||||
: 'FileManager.total.allSelectedFallback'
|
||||
: 'FileManager.total.selectedCount';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.header}
|
||||
paddingInline={8}
|
||||
style={{
|
||||
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Center height={40} style={{ paddingInline: 4 }}>
|
||||
<Checkbox checked={allSelected} indeterminate={indeterminate} onChange={handleSelectAll} />
|
||||
</Center>
|
||||
<Flexbox
|
||||
className={styles.headerItem}
|
||||
justify={'center'}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
maxWidth: columnWidths.name,
|
||||
minWidth: columnWidths.name,
|
||||
paddingInline: 20,
|
||||
paddingInlineEnd: 16,
|
||||
position: 'relative',
|
||||
width: columnWidths.name,
|
||||
}}
|
||||
>
|
||||
{selectedCount > 0 || selectAllState === 'all'
|
||||
? t(selectedLabelKey, {
|
||||
count: selectedCount,
|
||||
ns: 'components',
|
||||
})
|
||||
: t('FileManager.title.title')}
|
||||
<ColumnResizeHandle
|
||||
column="name"
|
||||
currentWidth={columnWidths.name}
|
||||
maxWidth={1200}
|
||||
minWidth={200}
|
||||
onResize={(width) => updateColumnWidth('name', width)}
|
||||
/>
|
||||
</Flexbox>
|
||||
<Flexbox
|
||||
className={styles.headerItem}
|
||||
justify={'center'}
|
||||
style={{ flexShrink: 0, paddingInlineEnd: 16, position: 'relative' }}
|
||||
width={columnWidths.date}
|
||||
>
|
||||
{t('FileManager.title.createdAt')}
|
||||
<ColumnResizeHandle
|
||||
column="date"
|
||||
currentWidth={columnWidths.date}
|
||||
maxWidth={300}
|
||||
minWidth={120}
|
||||
onResize={(width) => updateColumnWidth('date', width)}
|
||||
/>
|
||||
</Flexbox>
|
||||
<Flexbox
|
||||
className={styles.headerItem}
|
||||
justify={'center'}
|
||||
style={{ flexShrink: 0, paddingInlineEnd: 16, position: 'relative' }}
|
||||
width={columnWidths.size}
|
||||
>
|
||||
{t('FileManager.title.size')}
|
||||
<ColumnResizeHandle
|
||||
column="size"
|
||||
currentWidth={columnWidths.size}
|
||||
maxWidth={200}
|
||||
minWidth={80}
|
||||
onResize={(width) => updateColumnWidth('size', width)}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<ListViewSelectAllHint
|
||||
dataLength={data.length}
|
||||
selectAllState={selectAllState}
|
||||
selectedCount={selectedCount}
|
||||
showSelectAllHint={showSelectAllHint}
|
||||
total={total}
|
||||
onSelectAllResources={handleSelectAllResources}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListViewHeader;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { Button, Flexbox } from '@lobehub/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SelectAllState } from '@/routes/(main)/resource/features/store/initialState';
|
||||
|
||||
import { styles } from './styles';
|
||||
|
||||
interface ListViewSelectAllHintProps {
|
||||
dataLength: number;
|
||||
onSelectAllResources: () => void;
|
||||
selectAllState: SelectAllState;
|
||||
selectedCount: number;
|
||||
showSelectAllHint: boolean;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
const ListViewSelectAllHint = ({
|
||||
dataLength,
|
||||
onSelectAllResources,
|
||||
selectedCount,
|
||||
selectAllState,
|
||||
showSelectAllHint,
|
||||
total,
|
||||
}: ListViewSelectAllHintProps) => {
|
||||
const { t } = useTranslation('components');
|
||||
const isAllResultsSelected = selectAllState === 'all' && total === selectedCount;
|
||||
|
||||
if (!showSelectAllHint) return null;
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} className={styles.selectAllHint} gap={6} wrap={'wrap'}>
|
||||
<span>
|
||||
{t(
|
||||
selectAllState === 'all'
|
||||
? total
|
||||
? isAllResultsSelected
|
||||
? 'FileManager.total.allSelectedCount'
|
||||
: 'FileManager.total.selectedCount'
|
||||
: 'FileManager.total.allSelectedFallback'
|
||||
: 'FileManager.total.loadedSelectedCount',
|
||||
{
|
||||
count: selectedCount,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
{selectAllState !== 'all' && (
|
||||
<Button size={'small'} type={'link'} onClick={onSelectAllResources}>
|
||||
{total && total > dataLength
|
||||
? t('FileManager.total.selectAll', {
|
||||
count: total,
|
||||
})
|
||||
: t('FileManager.total.selectAllFallback')}
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListViewSelectAllHint;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Center, Checkbox, Flexbox, Skeleton } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
|
||||
import { FILE_DATE_WIDTH, FILE_SIZE_WIDTH } from './ListItem';
|
||||
import { FILE_DATE_WIDTH, FILE_SIZE_WIDTH } from './ListItem/constants';
|
||||
|
||||
interface ListViewSkeletonProps {
|
||||
columnWidths?: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import type { RefObject } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { isExplorerItemSelected } from '@/routes/(main)/resource/features/store/selectors';
|
||||
import type { FileListItem } from '@/types/files';
|
||||
|
||||
import { useExplorerSelectionActions } from '../hooks/useExplorerSelection';
|
||||
import FileListItemComponent from './ListItem';
|
||||
import { useExplorerInfiniteScroll } from './useExplorerInfiniteScroll';
|
||||
|
||||
interface VirtualizedFileListProps {
|
||||
columnWidths: {
|
||||
date: number;
|
||||
name: number;
|
||||
size: number;
|
||||
};
|
||||
data: FileListItem[];
|
||||
hasMore: boolean;
|
||||
virtuosoRef: RefObject<VirtuosoHandle | null>;
|
||||
}
|
||||
|
||||
const VirtualizedFileList = ({
|
||||
columnWidths,
|
||||
data,
|
||||
hasMore,
|
||||
virtuosoRef,
|
||||
}: VirtualizedFileListProps) => {
|
||||
const {
|
||||
clearSelectAllState,
|
||||
selectAllState,
|
||||
selectedFileIds,
|
||||
setSelectedFileIds,
|
||||
toggleItemSelection,
|
||||
} = useExplorerSelectionActions(data);
|
||||
const { Footer, handleEndReached } = useExplorerInfiniteScroll({
|
||||
columnWidths,
|
||||
dataLength: data.length,
|
||||
hasMore,
|
||||
});
|
||||
const dataRef = useRef<FileListItem[]>(data);
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
return useResourceManagerStore.subscribe(
|
||||
(s) => s.selectedFileIds.length,
|
||||
(selectedCount) => {
|
||||
if (selectedCount === 0) {
|
||||
lastSelectedIndexRef.current = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(id: string, checked: boolean, shiftKey: boolean, clickedIndex: number) => {
|
||||
if (shiftKey && lastSelectedIndexRef.current !== null && dataRef.current.length > 0) {
|
||||
clearSelectAllState();
|
||||
|
||||
const currentSelected = useResourceManagerStore.getState().selectedFileIds;
|
||||
const start = Math.min(lastSelectedIndexRef.current, clickedIndex);
|
||||
const end = Math.max(lastSelectedIndexRef.current, clickedIndex);
|
||||
const rangeIds = dataRef.current.slice(start, end + 1).map((item) => item.id);
|
||||
const nextSelected = new Set(currentSelected);
|
||||
|
||||
for (const rangeId of rangeIds) {
|
||||
nextSelected.add(rangeId);
|
||||
}
|
||||
|
||||
setSelectedFileIds(Array.from(nextSelected));
|
||||
} else {
|
||||
toggleItemSelection(id, checked);
|
||||
}
|
||||
|
||||
lastSelectedIndexRef.current = clickedIndex;
|
||||
},
|
||||
[clearSelectAllState, setSelectedFileIds, toggleItemSelection],
|
||||
);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
components={{ Footer }}
|
||||
data={data}
|
||||
defaultItemHeight={48}
|
||||
endReached={handleEndReached}
|
||||
increaseViewportBy={{ bottom: 800, top: 1200 }}
|
||||
initialItemCount={30}
|
||||
overscan={48 * 5}
|
||||
ref={virtuosoRef}
|
||||
style={{ height: 'calc(100vh - 100px)' }}
|
||||
itemContent={useCallback(
|
||||
(index: number, item: FileListItem) => {
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<FileListItemComponent
|
||||
columnWidths={columnWidths}
|
||||
index={index}
|
||||
key={item.id}
|
||||
selected={isExplorerItemSelected({
|
||||
id: item.id,
|
||||
selectAllState,
|
||||
selectedIds: selectedFileIds,
|
||||
})}
|
||||
onSelectedChange={handleSelectionChange}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[columnWidths, handleSelectionChange, selectAllState, selectedFileIds],
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualizedFileList;
|
||||
|
|
@ -1,467 +1,49 @@
|
|||
'use client';
|
||||
|
||||
import { Center, Checkbox, Flexbox } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import debug from 'debug';
|
||||
import { type DragEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { type VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { useRef } from 'react';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
import { useDragActive } from '@/routes/(main)/resource/features/DndContextWrapper';
|
||||
import { useFolderPath } from '@/routes/(main)/resource/features/hooks/useFolderPath';
|
||||
import {
|
||||
useResourceManagerFetchFolderBreadcrumb,
|
||||
useResourceManagerStore,
|
||||
} from '@/routes/(main)/resource/features/store';
|
||||
import { sortFileList } from '@/routes/(main)/resource/features/store/selectors';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useFetchResources } from '@/store/file/slices/resource/hooks';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { INITIAL_STATUS } from '@/store/global/initialState';
|
||||
import { type AsyncTaskStatus } from '@/types/asyncTask';
|
||||
import { type FileListItem as FileListItemType } from '@/types/files';
|
||||
import type { ResourceQueryParams } from '@/types/resource';
|
||||
|
||||
import ColumnResizeHandle from './ColumnResizeHandle';
|
||||
import FileListItem from './ListItem';
|
||||
import ListViewDropZone from './ListViewDropZone';
|
||||
import ListViewHeader from './ListViewHeader';
|
||||
import ListViewSkeleton from './Skeleton';
|
||||
import { styles } from './styles';
|
||||
import { useExplorerListData } from './useExplorerListData';
|
||||
import VirtualizedFileList from './VirtualizedFileList';
|
||||
|
||||
const log = debug('resource-manager:list-view');
|
||||
interface ListViewProps {
|
||||
isLoading?: boolean;
|
||||
isValidating?: boolean;
|
||||
queryParams: ResourceQueryParams;
|
||||
}
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
dropZone: css`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
`,
|
||||
dropZoneActive: css`
|
||||
background: ${cssVar.colorPrimaryBg};
|
||||
outline: 1px dashed ${cssVar.colorPrimaryBorder};
|
||||
outline-offset: -4px;
|
||||
`,
|
||||
header: css`
|
||||
min-width: 800px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
headerItem: css`
|
||||
height: 100%;
|
||||
padding-block: 6px;
|
||||
padding-inline: 0 24px;
|
||||
`,
|
||||
scrollContainer: css`
|
||||
overflow: auto hidden;
|
||||
flex: 1;
|
||||
`,
|
||||
}));
|
||||
|
||||
const ListView = memo(function ListView() {
|
||||
const [
|
||||
libraryId,
|
||||
category,
|
||||
selectFileIds,
|
||||
setSelectedFileIds,
|
||||
pendingRenameItemId,
|
||||
sorter,
|
||||
sortType,
|
||||
storeIsTransitioning,
|
||||
] = useResourceManagerStore((s) => [
|
||||
s.libraryId,
|
||||
s.category,
|
||||
s.selectedFileIds,
|
||||
s.setSelectedFileIds,
|
||||
s.pendingRenameItemId,
|
||||
s.sorter,
|
||||
s.sortType,
|
||||
s.isTransitioning,
|
||||
]);
|
||||
|
||||
// Access column widths from Global store
|
||||
const columnWidths = useGlobalStore(
|
||||
(s) => s.status.resourceManagerColumnWidths || INITIAL_STATUS.resourceManagerColumnWidths,
|
||||
);
|
||||
const updateColumnWidth = useGlobalStore((s) => s.updateResourceManagerColumnWidth);
|
||||
|
||||
const { t } = useTranslation(['components', 'file']);
|
||||
const ListView = ({ isLoading, isValidating, queryParams }: ListViewProps) => {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const isDragActive = useDragActive();
|
||||
const [isDropZoneActive, setIsDropZoneActive] = useState(false);
|
||||
const [isAnyRowHovered, setIsAnyRowHovered] = useState(false);
|
||||
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoScrollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const { currentFolderSlug } = useFolderPath();
|
||||
const { data: folderBreadcrumb } = useResourceManagerFetchFolderBreadcrumb(currentFolderSlug);
|
||||
|
||||
// Get current folder ID - either from breadcrumb or null for root
|
||||
const currentFolderId = folderBreadcrumb?.at(-1)?.id || null;
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
category: libraryId ? undefined : category,
|
||||
libraryId,
|
||||
parentId: currentFolderSlug || null,
|
||||
showFilesInKnowledgeBase: false,
|
||||
sortType,
|
||||
sorter,
|
||||
}),
|
||||
[category, currentFolderSlug, libraryId, sorter, sortType],
|
||||
);
|
||||
|
||||
const { isLoading, isValidating } = useFetchResources(queryParams);
|
||||
const { queryParams: currentQueryParams, hasMore, loadMoreResources } = useFileStore();
|
||||
|
||||
const isNavigating = useMemo(() => {
|
||||
if (!currentQueryParams || !queryParams) return false;
|
||||
|
||||
return (
|
||||
currentQueryParams.libraryId !== queryParams.libraryId ||
|
||||
currentQueryParams.parentId !== queryParams.parentId ||
|
||||
currentQueryParams.category !== queryParams.category
|
||||
);
|
||||
}, [currentQueryParams, queryParams]);
|
||||
|
||||
const resourceList = useFileStore((s) => s.resourceList);
|
||||
|
||||
// Map ResourceItem[] to FileListItem[] for compatibility
|
||||
const rawData =
|
||||
resourceList?.map<FileListItemType>((item) => ({
|
||||
...item,
|
||||
chunkCount: item.chunkCount ?? null,
|
||||
chunkingError: item.chunkingError ?? null,
|
||||
chunkingStatus: (item.chunkingStatus ?? null) as AsyncTaskStatus | null,
|
||||
embeddingError: item.embeddingError ?? null,
|
||||
embeddingStatus: (item.embeddingStatus ?? null) as AsyncTaskStatus | null,
|
||||
finishEmbedding: item.finishEmbedding ?? false,
|
||||
url: item.url ?? '',
|
||||
})) ?? [];
|
||||
|
||||
// Sort data using current sort settings
|
||||
const data = sortFileList(rawData, sorter, sortType) || [];
|
||||
|
||||
const dataLength = data.length;
|
||||
const effectiveIsLoading = isLoading ?? false;
|
||||
const effectiveIsNavigating = isNavigating ?? false;
|
||||
const effectiveIsTransitioning = storeIsTransitioning ?? false;
|
||||
const effectiveIsValidating = isValidating ?? false;
|
||||
|
||||
const showSkeleton =
|
||||
(effectiveIsLoading && dataLength === 0) ||
|
||||
(effectiveIsNavigating && effectiveIsValidating) ||
|
||||
effectiveIsTransitioning;
|
||||
|
||||
const dataRef = useRef<FileListItemType[]>(data);
|
||||
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
// Handle selection change with shift-click support for range selection
|
||||
const handleSelectionChange = useCallback(
|
||||
(id: string, checked: boolean, shiftKey: boolean, clickedIndex: number) => {
|
||||
// Always get the latest state from the store to avoid stale closure issues
|
||||
const currentSelected = useResourceManagerStore.getState().selectedFileIds;
|
||||
const lastIndex = lastSelectedIndexRef.current;
|
||||
const list = dataRef.current;
|
||||
|
||||
if (shiftKey && lastIndex !== null && list.length > 0) {
|
||||
// Shift-click: select range from lastIndex to current index
|
||||
const start = Math.min(lastIndex, clickedIndex);
|
||||
const end = Math.max(lastIndex, clickedIndex);
|
||||
const rangeIds = list
|
||||
.slice(start, end + 1)
|
||||
.filter(Boolean)
|
||||
.map((item) => item.id);
|
||||
|
||||
// Merge with existing selection
|
||||
const prevSet = new Set(currentSelected);
|
||||
rangeIds.forEach((rangeId) => prevSet.add(rangeId));
|
||||
setSelectedFileIds(Array.from(prevSet));
|
||||
} else {
|
||||
// Regular click: toggle single item
|
||||
if (checked) {
|
||||
setSelectedFileIds([...currentSelected, id]);
|
||||
} else {
|
||||
setSelectedFileIds(currentSelected.filter((item) => item !== id));
|
||||
}
|
||||
}
|
||||
lastSelectedIndexRef.current = clickedIndex;
|
||||
},
|
||||
[setSelectedFileIds],
|
||||
);
|
||||
|
||||
// Clean up invalid selections when data changes
|
||||
useEffect(() => {
|
||||
if (selectFileIds.length > 0) {
|
||||
const validFileIds = new Set(data.map((item) => item?.id).filter(Boolean));
|
||||
const filteredSelection = selectFileIds.filter((id) => validFileIds.has(id));
|
||||
if (filteredSelection.length !== selectFileIds.length) {
|
||||
setSelectedFileIds(filteredSelection);
|
||||
}
|
||||
}
|
||||
}, [data, selectFileIds, setSelectedFileIds]);
|
||||
|
||||
// Reset last selected index when all selections are cleared
|
||||
useEffect(() => {
|
||||
if (selectFileIds.length === 0) {
|
||||
lastSelectedIndexRef.current = null;
|
||||
}
|
||||
}, [selectFileIds.length]);
|
||||
|
||||
// Calculate select all checkbox state
|
||||
const { allSelected, indeterminate } = useMemo(() => {
|
||||
const fileCount = data.length;
|
||||
const selectedCount = selectFileIds.length;
|
||||
return {
|
||||
allSelected: fileCount > 0 && selectedCount === fileCount,
|
||||
indeterminate: selectedCount > 0 && selectedCount < fileCount,
|
||||
};
|
||||
}, [data, selectFileIds]);
|
||||
|
||||
// Handle select all checkbox change
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedFileIds([]);
|
||||
} else {
|
||||
setSelectedFileIds(data.map((item) => item.id));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle automatic load more when reaching the end
|
||||
const handleEndReached = useCallback(async () => {
|
||||
log('handleEndReached', hasMore, isLoadingMore);
|
||||
|
||||
if (!hasMore || isLoadingMore) return;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
await loadMoreResources();
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [hasMore, loadMoreResources, isLoadingMore]);
|
||||
|
||||
// Clear auto-scroll timers
|
||||
const clearScrollTimers = useCallback(() => {
|
||||
if (scrollTimerRef.current) {
|
||||
clearTimeout(scrollTimerRef.current);
|
||||
scrollTimerRef.current = null;
|
||||
}
|
||||
if (autoScrollIntervalRef.current) {
|
||||
clearInterval(autoScrollIntervalRef.current);
|
||||
autoScrollIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Drop zone handlers for dragging to blank space
|
||||
const handleDropZoneDragOver = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!isDragActive) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropZoneActive(true);
|
||||
},
|
||||
[isDragActive],
|
||||
);
|
||||
|
||||
const handleDropZoneDragLeave = useCallback(() => {
|
||||
setIsDropZoneActive(false);
|
||||
clearScrollTimers();
|
||||
}, [clearScrollTimers]);
|
||||
|
||||
const handleDropZoneDrop = useCallback(() => {
|
||||
setIsDropZoneActive(false);
|
||||
clearScrollTimers();
|
||||
}, [clearScrollTimers]);
|
||||
|
||||
// Handle auto-scroll during drag
|
||||
const handleDragMove = useCallback(
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isDragActive || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mouseY = e.clientY;
|
||||
const bottomThreshold = 200; // pixels from bottom edge
|
||||
const distanceFromBottom = rect.bottom - mouseY;
|
||||
|
||||
// Check if mouse is near the bottom edge
|
||||
if (distanceFromBottom > 0 && distanceFromBottom <= bottomThreshold) {
|
||||
// If not already started, start the 2-second timer
|
||||
if (!scrollTimerRef.current && !autoScrollIntervalRef.current) {
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
// After 2 seconds, start auto-scrolling
|
||||
autoScrollIntervalRef.current = setInterval(() => {
|
||||
virtuosoRef.current?.scrollBy({ top: 50 });
|
||||
}, 100); // Scroll every 100ms for smooth scrolling
|
||||
scrollTimerRef.current = null;
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
// Mouse moved away from bottom edge, clear timers
|
||||
clearScrollTimers();
|
||||
}
|
||||
},
|
||||
[isDragActive, clearScrollTimers],
|
||||
);
|
||||
|
||||
// Clean up timers when drag ends or component unmounts
|
||||
useEffect(() => {
|
||||
if (!isDragActive) {
|
||||
clearScrollTimers();
|
||||
}
|
||||
}, [isDragActive, clearScrollTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearScrollTimers();
|
||||
};
|
||||
}, [clearScrollTimers]);
|
||||
|
||||
// Memoize footer component to show skeleton loaders when loading more
|
||||
// eslint-disable-next-line @eslint-react/no-nested-component-definitions
|
||||
const Footer = useCallback(() => {
|
||||
if (isLoadingMore && hasMore) return <ListViewSkeleton columnWidths={columnWidths} />;
|
||||
|
||||
// Leave some padding at the end when there are no more pages,
|
||||
// so users can clearly feel they've reached the end of the list.
|
||||
if (hasMore === false && dataLength > 0) return <div aria-hidden style={{ height: 96 }} />;
|
||||
|
||||
return null;
|
||||
}, [columnWidths, dataLength, hasMore, isLoadingMore]);
|
||||
const { columnWidths, currentFolderId, data, hasMore, showSkeleton } = useExplorerListData({
|
||||
isLoading,
|
||||
isValidating,
|
||||
queryParams,
|
||||
});
|
||||
|
||||
if (showSkeleton) return <ListViewSkeleton columnWidths={columnWidths} />;
|
||||
|
||||
return (
|
||||
<Flexbox height={'100%'}>
|
||||
<div className={styles.scrollContainer}>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.header}
|
||||
paddingInline={8}
|
||||
style={{
|
||||
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Center height={40} style={{ paddingInline: 4 }}>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={indeterminate}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
</Center>
|
||||
<Flexbox
|
||||
className={styles.headerItem}
|
||||
justify={'center'}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
maxWidth: columnWidths.name,
|
||||
minWidth: columnWidths.name,
|
||||
paddingInline: 20,
|
||||
paddingInlineEnd: 16,
|
||||
position: 'relative',
|
||||
width: columnWidths.name,
|
||||
}}
|
||||
>
|
||||
{selectFileIds.length > 0
|
||||
? t('FileManager.total.selectedCount', {
|
||||
count: selectFileIds.length,
|
||||
ns: 'components',
|
||||
})
|
||||
: t('FileManager.title.title')}
|
||||
<ColumnResizeHandle
|
||||
column="name"
|
||||
currentWidth={columnWidths.name}
|
||||
maxWidth={1200}
|
||||
minWidth={200}
|
||||
onResize={(width) => updateColumnWidth('name', width)}
|
||||
/>
|
||||
</Flexbox>
|
||||
<Flexbox
|
||||
className={styles.headerItem}
|
||||
justify={'center'}
|
||||
style={{ flexShrink: 0, paddingInlineEnd: 16, position: 'relative' }}
|
||||
width={columnWidths.date}
|
||||
>
|
||||
{t('FileManager.title.createdAt')}
|
||||
<ColumnResizeHandle
|
||||
column="date"
|
||||
currentWidth={columnWidths.date}
|
||||
maxWidth={300}
|
||||
minWidth={120}
|
||||
onResize={(width) => updateColumnWidth('date', width)}
|
||||
/>
|
||||
</Flexbox>
|
||||
<Flexbox
|
||||
className={styles.headerItem}
|
||||
justify={'center'}
|
||||
style={{ flexShrink: 0, paddingInlineEnd: 16, position: 'relative' }}
|
||||
width={columnWidths.size}
|
||||
>
|
||||
{t('FileManager.title.size')}
|
||||
<ColumnResizeHandle
|
||||
column="size"
|
||||
currentWidth={columnWidths.size}
|
||||
maxWidth={200}
|
||||
minWidth={80}
|
||||
onResize={(width) => updateColumnWidth('size', width)}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<div
|
||||
data-drop-target-id={currentFolderId || undefined}
|
||||
data-is-folder="true"
|
||||
ref={containerRef}
|
||||
style={{ overflow: 'hidden', position: 'relative' }}
|
||||
className={cx(
|
||||
styles.dropZone,
|
||||
isDropZoneActive && styles.dropZoneActive,
|
||||
isAnyRowHovered && 'any-row-hovered',
|
||||
)}
|
||||
onDragLeave={handleDropZoneDragLeave}
|
||||
onDrop={handleDropZoneDrop}
|
||||
onDragOver={(e) => {
|
||||
handleDropZoneDragOver(e);
|
||||
handleDragMove(e);
|
||||
}}
|
||||
>
|
||||
<Virtuoso
|
||||
components={{ Footer }}
|
||||
<ListViewHeader columnWidths={columnWidths} data={data} hasMore={hasMore} />
|
||||
<ListViewDropZone currentFolderId={currentFolderId} virtuosoRef={virtuosoRef}>
|
||||
<VirtualizedFileList
|
||||
columnWidths={columnWidths}
|
||||
data={data}
|
||||
defaultItemHeight={48}
|
||||
endReached={handleEndReached}
|
||||
increaseViewportBy={{ bottom: 800, top: 1200 }}
|
||||
initialItemCount={30}
|
||||
overscan={48 * 5}
|
||||
ref={virtuosoRef}
|
||||
style={{ height: 'calc(100vh - 100px)' }}
|
||||
itemContent={(index, item) => {
|
||||
if (!item) return null;
|
||||
return (
|
||||
<FileListItem
|
||||
columnWidths={columnWidths}
|
||||
index={index}
|
||||
isAnyRowHovered={isAnyRowHovered}
|
||||
key={item.id}
|
||||
pendingRenameItemId={pendingRenameItemId}
|
||||
selected={selectFileIds.includes(item.id)}
|
||||
onHoverChange={setIsAnyRowHovered}
|
||||
onSelectedChange={handleSelectionChange}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
hasMore={hasMore}
|
||||
virtuosoRef={virtuosoRef}
|
||||
/>
|
||||
</div>
|
||||
</ListViewDropZone>
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default ListView;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
|
||||
export const styles = createStaticStyles(({ css }) => ({
|
||||
dropZone: css`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
`,
|
||||
dropZoneActive: css`
|
||||
background: ${cssVar.colorPrimaryBg};
|
||||
outline: 1px dashed ${cssVar.colorPrimaryBorder};
|
||||
outline-offset: -4px;
|
||||
`,
|
||||
header: css`
|
||||
min-width: 800px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
headerItem: css`
|
||||
height: 100%;
|
||||
padding-block: 6px;
|
||||
padding-inline: 0 24px;
|
||||
`,
|
||||
scrollContainer: css`
|
||||
overflow: auto hidden;
|
||||
flex: 1;
|
||||
`,
|
||||
selectAllHint: css`
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
inset-block-start: 40px;
|
||||
|
||||
min-width: 800px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import type { DragEvent, RefObject } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
import { useDragActive } from '@/routes/(main)/resource/features/DndContextWrapper';
|
||||
|
||||
export const useExplorerDropZone = (virtuosoRef: RefObject<VirtuosoHandle | null>) => {
|
||||
const isDragActive = useDragActive();
|
||||
const [isDropZoneActive, setIsDropZoneActive] = useState(false);
|
||||
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoScrollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const clearScrollTimers = useCallback(() => {
|
||||
if (scrollTimerRef.current) {
|
||||
clearTimeout(scrollTimerRef.current);
|
||||
scrollTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (autoScrollIntervalRef.current) {
|
||||
clearInterval(autoScrollIntervalRef.current);
|
||||
autoScrollIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDropZoneActive(false);
|
||||
clearScrollTimers();
|
||||
}, [clearScrollTimers]);
|
||||
|
||||
const handleDrop = useCallback(() => {
|
||||
setIsDropZoneActive(false);
|
||||
clearScrollTimers();
|
||||
}, [clearScrollTimers]);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isDragActive || !containerRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropZoneActive(true);
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const distanceFromBottom = rect.bottom - e.clientY;
|
||||
const bottomThreshold = 200;
|
||||
|
||||
if (distanceFromBottom > 0 && distanceFromBottom <= bottomThreshold) {
|
||||
if (!scrollTimerRef.current && !autoScrollIntervalRef.current) {
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
autoScrollIntervalRef.current = setInterval(() => {
|
||||
virtuosoRef.current?.scrollBy({ top: 50 });
|
||||
}, 100);
|
||||
scrollTimerRef.current = null;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
clearScrollTimers();
|
||||
},
|
||||
[clearScrollTimers, isDragActive, virtuosoRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragActive) {
|
||||
clearScrollTimers();
|
||||
}
|
||||
}, [clearScrollTimers, isDragActive]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearScrollTimers();
|
||||
},
|
||||
[clearScrollTimers],
|
||||
);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
isDropZoneActive,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useFileStore } from '@/store/file';
|
||||
|
||||
import ListViewSkeleton from './Skeleton';
|
||||
|
||||
interface UseExplorerInfiniteScrollOptions {
|
||||
columnWidths: {
|
||||
date: number;
|
||||
name: number;
|
||||
size: number;
|
||||
};
|
||||
dataLength: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export const useExplorerInfiniteScroll = ({
|
||||
columnWidths,
|
||||
dataLength,
|
||||
hasMore,
|
||||
}: UseExplorerInfiniteScrollOptions) => {
|
||||
const loadMoreResources = useFileStore((s) => s.loadMoreResources);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
const handleEndReached = useCallback(async () => {
|
||||
if (!hasMore || isLoadingMore) return;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
await loadMoreResources();
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [hasMore, isLoadingMore, loadMoreResources]);
|
||||
|
||||
const Footer = useCallback(() => {
|
||||
if (isLoadingMore && hasMore) return <ListViewSkeleton columnWidths={columnWidths} />;
|
||||
if (hasMore === false && dataLength > 0) return <div aria-hidden style={{ height: 96 }} />;
|
||||
|
||||
return null;
|
||||
}, [columnWidths, dataLength, hasMore, isLoadingMore]);
|
||||
|
||||
return {
|
||||
Footer,
|
||||
handleEndReached,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { useCurrentFolderId } from '@/routes/(main)/resource/features/hooks/useCurrentFolderId';
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { sortFileList } from '@/routes/(main)/resource/features/store/selectors';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { INITIAL_STATUS } from '@/store/global/initialState';
|
||||
import type { AsyncTaskStatus } from '@/types/asyncTask';
|
||||
import type { FileListItem } from '@/types/files';
|
||||
import type { ResourceQueryParams } from '@/types/resource';
|
||||
|
||||
interface UseExplorerListDataParams {
|
||||
isLoading?: boolean;
|
||||
isValidating?: boolean;
|
||||
queryParams: ResourceQueryParams;
|
||||
}
|
||||
|
||||
export const useExplorerListData = ({
|
||||
isLoading,
|
||||
isValidating,
|
||||
queryParams,
|
||||
}: UseExplorerListDataParams) => {
|
||||
const [sorter, sortType] = useResourceManagerStore((s) => [s.sorter, s.sortType]);
|
||||
const columnWidths = useGlobalStore(
|
||||
(s) => s.status.resourceManagerColumnWidths || INITIAL_STATUS.resourceManagerColumnWidths,
|
||||
);
|
||||
const currentFolderId = useCurrentFolderId();
|
||||
const { currentQueryParams, hasMore, resourceList } = useFileStore((s) => ({
|
||||
currentQueryParams: s.queryParams,
|
||||
hasMore: s.hasMore,
|
||||
resourceList: s.resourceList,
|
||||
}));
|
||||
|
||||
const isNavigating = useMemo(() => {
|
||||
if (!currentQueryParams) return false;
|
||||
|
||||
return (
|
||||
currentQueryParams.libraryId !== queryParams.libraryId ||
|
||||
currentQueryParams.parentId !== queryParams.parentId ||
|
||||
currentQueryParams.category !== queryParams.category
|
||||
);
|
||||
}, [currentQueryParams, queryParams]);
|
||||
|
||||
const rawData = useMemo(
|
||||
() =>
|
||||
resourceList?.map<FileListItem>((item) => ({
|
||||
...item,
|
||||
chunkCount: item.chunkCount ?? null,
|
||||
chunkingError: item.chunkingError ?? null,
|
||||
chunkingStatus: (item.chunkingStatus ?? null) as AsyncTaskStatus | null,
|
||||
embeddingError: item.embeddingError ?? null,
|
||||
embeddingStatus: (item.embeddingStatus ?? null) as AsyncTaskStatus | null,
|
||||
finishEmbedding: item.finishEmbedding ?? false,
|
||||
url: item.url ?? '',
|
||||
})) ?? [],
|
||||
[resourceList],
|
||||
);
|
||||
|
||||
const data = useMemo(
|
||||
() => sortFileList(rawData, sorter, sortType) || [],
|
||||
[rawData, sorter, sortType],
|
||||
);
|
||||
|
||||
const showSkeleton =
|
||||
((isLoading ?? false) && data.length === 0) || ((isNavigating ?? false) && !!isValidating);
|
||||
|
||||
return {
|
||||
columnWidths,
|
||||
currentFolderId,
|
||||
data,
|
||||
hasMore,
|
||||
showSkeleton,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import { isExplorerItemSelected } from '@/routes/(main)/resource/features/store/selectors';
|
||||
import { type FileListItem } from '@/types/files';
|
||||
|
||||
import MasonryFileItem from '.';
|
||||
|
|
@ -7,8 +8,9 @@ import MasonryFileItem from '.';
|
|||
interface MasonryItemWrapperProps {
|
||||
context: {
|
||||
knowledgeBaseId?: string;
|
||||
onSelectedChange: (id: string, checked: boolean) => void;
|
||||
selectAllState: 'all' | 'loaded' | 'none';
|
||||
selectFileIds: string[];
|
||||
setSelectedFileIds: (ids: string[]) => void;
|
||||
};
|
||||
data: FileListItem;
|
||||
index: number;
|
||||
|
|
@ -24,14 +26,12 @@ const MasonryItemWrapper = memo<MasonryItemWrapperProps>(({ data: item, context
|
|||
<div style={{ padding: '8px 4px' }}>
|
||||
<MasonryFileItem
|
||||
knowledgeBaseId={context.knowledgeBaseId}
|
||||
selected={context.selectFileIds.includes(item.id)}
|
||||
onSelectedChange={(id, checked) => {
|
||||
if (checked) {
|
||||
context.setSelectedFileIds([...context.selectFileIds, id]);
|
||||
} else {
|
||||
context.setSelectedFileIds(context.selectFileIds.filter((item) => item !== id));
|
||||
}
|
||||
}}
|
||||
selected={isExplorerItemSelected({
|
||||
id: item.id,
|
||||
selectAllState: context.selectAllState,
|
||||
selectedIds: context.selectFileIds,
|
||||
})}
|
||||
onSelectedChange={context.onSelectedChange}
|
||||
{...item}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from '
|
|||
import {
|
||||
getTransparentDragImage,
|
||||
useDragActive,
|
||||
useDragState,
|
||||
useSetCurrentDrag,
|
||||
} from '@/routes/(main)/resource/features/DndContextWrapper';
|
||||
import { documentService } from '@/services/document';
|
||||
import { type FileListItem } from '@/types/files';
|
||||
|
|
@ -204,7 +204,7 @@ const MasonryFileItem = memo<MasonryFileItemProps>(
|
|||
const [isLoadingMarkdown, setIsLoadingMarkdown] = useState(false);
|
||||
|
||||
const isDragActive = useDragActive();
|
||||
const { setCurrentDrag } = useDragState();
|
||||
const setCurrentDrag = useSetCurrentDrag();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { Center } from '@lobehub/ui';
|
||||
import { Button, Center, Checkbox, Flexbox } from '@lobehub/ui';
|
||||
import { VirtuosoMasonry } from '@virtuoso.dev/masonry';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { type UIEvent } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -10,55 +10,73 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { sortFileList } from '@/routes/(main)/resource/features/store/selectors';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useFetchResources } from '@/store/file/slices/resource/hooks';
|
||||
import { type FileListItem } from '@/types/files';
|
||||
import type { ResourceQueryParams } from '@/types/resource';
|
||||
|
||||
import {
|
||||
useExplorerSelectionActions,
|
||||
useExplorerSelectionSummary,
|
||||
} from '../hooks/useExplorerSelection';
|
||||
import { useMasonryColumnCount } from '../useMasonryColumnCount';
|
||||
import MasonryItemWrapper from './MasonryItem/MasonryItemWrapper';
|
||||
import MasonryViewSkeleton from './Skeleton';
|
||||
import { useMasonryViewState } from './useMasonryViewState';
|
||||
|
||||
const MasonryView = memo(function MasonryView() {
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
selectAllHint: css`
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
inset-block-start: 53px;
|
||||
|
||||
padding-block: 8px;
|
||||
padding-inline: 4px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
toolbar: css`
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
inset-block-start: 0;
|
||||
|
||||
padding-block: 12px;
|
||||
padding-inline: 4px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface MasonryViewProps {
|
||||
isLoading?: boolean;
|
||||
isValidating?: boolean;
|
||||
queryParams: ResourceQueryParams;
|
||||
}
|
||||
|
||||
const MasonryView = memo(function MasonryView({
|
||||
isLoading,
|
||||
isValidating,
|
||||
queryParams,
|
||||
}: MasonryViewProps) {
|
||||
// Access all state from Resource Manager store
|
||||
const [
|
||||
libraryId,
|
||||
category,
|
||||
selectedFileIds,
|
||||
setSelectedFileIds,
|
||||
storeIsMasonryReady,
|
||||
sorter,
|
||||
sortType,
|
||||
storeIsTransitioning,
|
||||
] = useResourceManagerStore((s) => [
|
||||
const [libraryId, viewMode, sorter, sortType] = useResourceManagerStore((s) => [
|
||||
s.libraryId,
|
||||
s.category,
|
||||
s.selectedFileIds,
|
||||
s.setSelectedFileIds,
|
||||
s.isMasonryReady,
|
||||
s.viewMode,
|
||||
s.sorter,
|
||||
s.sortType,
|
||||
s.isTransitioning,
|
||||
]);
|
||||
|
||||
const { t } = useTranslation('file');
|
||||
const { t } = useTranslation(['components', 'file']);
|
||||
const columnCount = useMasonryColumnCount();
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
// NEW: Read from resource store instead of fetching independently
|
||||
const resourceList = useFileStore((s) => s.resourceList);
|
||||
const total = useFileStore((s) => s.total);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
category: libraryId ? undefined : category,
|
||||
libraryId,
|
||||
parentId: null,
|
||||
showFilesInKnowledgeBase: false,
|
||||
sortType,
|
||||
sorter,
|
||||
}),
|
||||
[category, libraryId, sorter, sortType],
|
||||
);
|
||||
|
||||
const { isLoading, isValidating } = useFetchResources(queryParams);
|
||||
const { queryParams: currentQueryParams, hasMore, loadMoreResources } = useFileStore();
|
||||
|
||||
const isNavigating = useMemo(() => {
|
||||
|
|
@ -72,54 +90,58 @@ const MasonryView = memo(function MasonryView() {
|
|||
}, [currentQueryParams, queryParams]);
|
||||
|
||||
// Map ResourceItem[] to FileListItem[] for compatibility
|
||||
const rawData = resourceList?.map(
|
||||
(item): FileListItem => ({
|
||||
chunkCount: item.chunkCount ?? null,
|
||||
chunkingError: item.chunkingError ?? null,
|
||||
chunkingStatus: (item.chunkingStatus as any) ?? null,
|
||||
content: item.content,
|
||||
createdAt: item.createdAt,
|
||||
editorData: item.editorData,
|
||||
embeddingError: item.embeddingError ?? null,
|
||||
embeddingStatus: (item.embeddingStatus as any) ?? null,
|
||||
fileType: item.fileType,
|
||||
finishEmbedding: item.finishEmbedding ?? false,
|
||||
id: item.id,
|
||||
metadata: item.metadata,
|
||||
name: item.name,
|
||||
parentId: item.parentId,
|
||||
size: item.size,
|
||||
slug: item.slug,
|
||||
sourceType: item.sourceType,
|
||||
updatedAt: item.updatedAt,
|
||||
url: item.url ?? '',
|
||||
}),
|
||||
const rawData = useMemo(
|
||||
() =>
|
||||
resourceList?.map(
|
||||
(item): FileListItem => ({
|
||||
chunkCount: item.chunkCount ?? null,
|
||||
chunkingError: item.chunkingError ?? null,
|
||||
chunkingStatus: (item.chunkingStatus as any) ?? null,
|
||||
content: item.content,
|
||||
createdAt: item.createdAt,
|
||||
editorData: item.editorData,
|
||||
embeddingError: item.embeddingError ?? null,
|
||||
embeddingStatus: (item.embeddingStatus as any) ?? null,
|
||||
fileType: item.fileType,
|
||||
finishEmbedding: item.finishEmbedding ?? false,
|
||||
id: item.id,
|
||||
metadata: item.metadata,
|
||||
name: item.name,
|
||||
parentId: item.parentId,
|
||||
size: item.size,
|
||||
slug: item.slug,
|
||||
sourceType: item.sourceType,
|
||||
updatedAt: item.updatedAt,
|
||||
url: item.url ?? '',
|
||||
}),
|
||||
) ?? [],
|
||||
[resourceList],
|
||||
);
|
||||
|
||||
// Sort data using current sort settings
|
||||
const data = sortFileList(rawData, sorter, sortType) || [];
|
||||
const data = useMemo(
|
||||
() => sortFileList(rawData, sorter, sortType) || [],
|
||||
[rawData, sorter, sortType],
|
||||
);
|
||||
|
||||
const dataLength = data.length;
|
||||
const effectiveIsLoading = isLoading ?? false;
|
||||
const effectiveIsNavigating = isNavigating ?? false;
|
||||
const effectiveIsValidating = isValidating ?? false;
|
||||
const effectiveIsTransitioning = storeIsTransitioning ?? false;
|
||||
const effectiveIsMasonryReady = storeIsMasonryReady;
|
||||
|
||||
const showSkeleton =
|
||||
(effectiveIsLoading && dataLength === 0) ||
|
||||
(effectiveIsNavigating && effectiveIsValidating) ||
|
||||
effectiveIsTransitioning ||
|
||||
!effectiveIsMasonryReady;
|
||||
|
||||
const masonryContext = useMemo(
|
||||
() => ({
|
||||
knowledgeBaseId: libraryId,
|
||||
selectFileIds: selectedFileIds,
|
||||
setSelectedFileIds,
|
||||
}),
|
||||
[libraryId, selectedFileIds, setSelectedFileIds],
|
||||
);
|
||||
const { isMasonryReady, showSkeleton } = useMasonryViewState({
|
||||
dataLength,
|
||||
isLoading: effectiveIsLoading,
|
||||
isNavigating: effectiveIsNavigating,
|
||||
isValidating: effectiveIsValidating,
|
||||
viewMode,
|
||||
});
|
||||
const { handleSelectAll, handleSelectAllResources, selectAllState, selectedFileIds, toggleItemSelection } =
|
||||
useExplorerSelectionActions(data);
|
||||
const { allSelected, indeterminate, selectedCount, showSelectAllHint } = useExplorerSelectionSummary({
|
||||
data,
|
||||
hasMore,
|
||||
});
|
||||
const isAllResultsSelected = selectAllState === 'all' && total === selectedCount;
|
||||
|
||||
// Handle automatic load more when scrolling to bottom
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
|
|
@ -131,7 +153,24 @@ const MasonryView = memo(function MasonryView() {
|
|||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [hasMore, loadMoreResources, isLoadingMore]);
|
||||
}, [hasMore, isLoadingMore, loadMoreResources]);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(id: string, checked: boolean) => {
|
||||
toggleItemSelection(id, checked);
|
||||
},
|
||||
[toggleItemSelection],
|
||||
);
|
||||
|
||||
const masonryContext = useMemo(
|
||||
() => ({
|
||||
knowledgeBaseId: libraryId,
|
||||
onSelectedChange: handleSelectionChange,
|
||||
selectAllState,
|
||||
selectFileIds: selectedFileIds,
|
||||
}),
|
||||
[handleSelectionChange, libraryId, selectAllState, selectedFileIds],
|
||||
);
|
||||
|
||||
// Handle scroll event to detect when near bottom
|
||||
const handleScroll = useCallback(
|
||||
|
|
@ -156,13 +195,74 @@ const MasonryView = memo(function MasonryView() {
|
|||
style={{
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
opacity: effectiveIsMasonryReady ? 1 : 0,
|
||||
opacity: isMasonryReady ? 1 : 0,
|
||||
overflowY: 'auto',
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div style={{ paddingBlockEnd: 24, paddingBlockStart: 12, paddingInline: 24 }}>
|
||||
<Flexbox horizontal align={'center'} className={styles.toolbar} gap={8}>
|
||||
<Checkbox checked={allSelected} indeterminate={indeterminate} onChange={handleSelectAll} />
|
||||
<span>
|
||||
{selectedCount > 0 || selectAllState === 'all'
|
||||
? t(
|
||||
selectAllState === 'all'
|
||||
? total
|
||||
? isAllResultsSelected
|
||||
? 'FileManager.total.allSelectedCount'
|
||||
: 'FileManager.total.selectedCount'
|
||||
: 'FileManager.total.allSelectedFallback'
|
||||
: 'FileManager.total.selectedCount',
|
||||
{
|
||||
count: selectedCount,
|
||||
ns: 'components',
|
||||
},
|
||||
)
|
||||
: t('FileManager.total.fileCount', {
|
||||
count: total || dataLength,
|
||||
ns: 'components',
|
||||
})}
|
||||
</span>
|
||||
</Flexbox>
|
||||
{showSelectAllHint && (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.selectAllHint}
|
||||
gap={6}
|
||||
paddingInline={4}
|
||||
wrap={'wrap'}
|
||||
>
|
||||
<span>
|
||||
{t(
|
||||
selectAllState === 'all'
|
||||
? total
|
||||
? isAllResultsSelected
|
||||
? 'FileManager.total.allSelectedCount'
|
||||
: 'FileManager.total.selectedCount'
|
||||
: 'FileManager.total.allSelectedFallback'
|
||||
: 'FileManager.total.loadedSelectedCount',
|
||||
{
|
||||
count: selectedCount,
|
||||
ns: 'components',
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
{selectAllState !== 'all' && (
|
||||
<Button size={'small'} type={'link'} onClick={handleSelectAllResources}>
|
||||
{total && total > dataLength
|
||||
? t('FileManager.total.selectAll', {
|
||||
count: total,
|
||||
ns: 'components',
|
||||
})
|
||||
: t('FileManager.total.selectAllFallback', {
|
||||
ns: 'components',
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
)}
|
||||
<VirtuosoMasonry
|
||||
ItemContent={MasonryItemWrapper}
|
||||
columnCount={columnCount}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import type { ViewMode } from '@/routes/(main)/resource/features/store/initialState';
|
||||
|
||||
interface UseMasonryViewStateOptions {
|
||||
dataLength: number;
|
||||
isLoading: boolean;
|
||||
isNavigating: boolean;
|
||||
isValidating: boolean;
|
||||
viewMode: ViewMode;
|
||||
}
|
||||
|
||||
export const useMasonryViewState = ({
|
||||
dataLength,
|
||||
isLoading,
|
||||
isNavigating,
|
||||
isValidating,
|
||||
viewMode: _viewMode,
|
||||
}: UseMasonryViewStateOptions) => {
|
||||
const showSkeleton = useMemo(
|
||||
() => (isLoading && dataLength === 0) || (isNavigating && isValidating),
|
||||
[dataLength, isLoading, isNavigating, isValidating],
|
||||
);
|
||||
|
||||
const isMasonryReady = !showSkeleton;
|
||||
|
||||
return {
|
||||
isMasonryReady,
|
||||
showSkeleton,
|
||||
};
|
||||
};
|
||||
|
|
@ -77,8 +77,15 @@ const SearchResultsOverlay = memo(() => {
|
|||
const masonryContext = useMemo(
|
||||
() => ({
|
||||
knowledgeBaseId: libraryId ?? undefined,
|
||||
onSelectedChange: (id: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedFileIds((prev) => [...prev, id]);
|
||||
} else {
|
||||
setSelectedFileIds((prev) => prev.filter((fid) => fid !== id));
|
||||
}
|
||||
},
|
||||
selectAllState: 'loaded' as const,
|
||||
selectFileIds: selectedFileIds,
|
||||
setSelectedFileIds,
|
||||
}),
|
||||
[libraryId, selectedFileIds],
|
||||
);
|
||||
|
|
@ -181,10 +188,8 @@ const SearchResultsOverlay = memo(() => {
|
|||
<FileListItemComponent
|
||||
columnWidths={columnWidths}
|
||||
index={index}
|
||||
isAnyRowHovered={false}
|
||||
key={item.id}
|
||||
selected={selectedFileIds.includes(item.id)}
|
||||
onHoverChange={() => {}}
|
||||
onSelectedChange={(id, checked) => {
|
||||
if (checked) {
|
||||
setSelectedFileIds((prev) => [...prev, id]);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { memo, useMemo } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RepoIcon from '@/components/LibIcon';
|
||||
import { useKnowledgeBaseListContext } from '@/features/ResourceManager/components/KnowledgeBaseListProvider';
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { useKnowledgeBaseStore } from '@/store/library';
|
||||
|
||||
|
|
@ -34,15 +35,13 @@ const BatchActionsDropdown = memo<BatchActionsDropdownProps>(({ selectCount, onA
|
|||
const { t } = useTranslation(['components', 'common', 'file', 'knowledgeBase']);
|
||||
const { modal, message } = App.useApp();
|
||||
|
||||
const [libraryId, selectedFileIds] = useResourceManagerStore((s) => [
|
||||
s.libraryId,
|
||||
s.selectedFileIds,
|
||||
const libraryId = useResourceManagerStore((s) => s.libraryId);
|
||||
const [resolveSelectedResourceIds, selectAllState] = useResourceManagerStore((s) => [
|
||||
s.resolveSelectedResourceIds,
|
||||
s.selectAllState,
|
||||
]);
|
||||
const [useFetchKnowledgeBaseList, addFilesToKnowledgeBase] = useKnowledgeBaseStore((s) => [
|
||||
s.useFetchKnowledgeBaseList,
|
||||
s.addFilesToKnowledgeBase,
|
||||
]);
|
||||
const { data: knowledgeBases } = useFetchKnowledgeBaseList();
|
||||
const addFilesToKnowledgeBase = useKnowledgeBaseStore((s) => s.addFilesToKnowledgeBase);
|
||||
const knowledgeBases = useKnowledgeBaseListContext();
|
||||
|
||||
const menuItems = useMemo<DropdownItem[]>(() => {
|
||||
const items: DropdownItem[] = [];
|
||||
|
|
@ -70,7 +69,7 @@ const BatchActionsDropdown = memo<BatchActionsDropdownProps>(({ selectCount, onA
|
|||
}
|
||||
|
||||
// Filter out current knowledge base and create submenu items
|
||||
const availableKnowledgeBases = (knowledgeBases || []).filter((kb) => kb.id !== libraryId);
|
||||
const availableKnowledgeBases = knowledgeBases.filter((kb) => kb.id !== libraryId);
|
||||
|
||||
const addToKnowledgeBaseSubmenu: DropdownItem[] = availableKnowledgeBases.map((kb) => ({
|
||||
disabled: selectCount === 0,
|
||||
|
|
@ -79,10 +78,11 @@ const BatchActionsDropdown = memo<BatchActionsDropdownProps>(({ selectCount, onA
|
|||
label: <span style={{ marginLeft: 8 }}>{kb.name}</span>,
|
||||
onClick: async () => {
|
||||
try {
|
||||
await addFilesToKnowledgeBase(kb.id, selectedFileIds);
|
||||
const effectiveSelectedIds = await resolveSelectedResourceIds();
|
||||
await addFilesToKnowledgeBase(kb.id, effectiveSelectedIds);
|
||||
message.success(
|
||||
t('addToKnowledgeBase.addSuccess', {
|
||||
count: selectCount,
|
||||
count: selectAllState === 'all' ? effectiveSelectedIds.length : selectCount,
|
||||
ns: 'knowledgeBase',
|
||||
}),
|
||||
);
|
||||
|
|
@ -172,9 +172,10 @@ const BatchActionsDropdown = memo<BatchActionsDropdownProps>(({ selectCount, onA
|
|||
}, [
|
||||
libraryId,
|
||||
selectCount,
|
||||
selectedFileIds,
|
||||
selectAllState,
|
||||
onActionClick,
|
||||
addFilesToKnowledgeBase,
|
||||
resolveSelectedResourceIds,
|
||||
t,
|
||||
modal,
|
||||
message,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { initialState } from '@/routes/(main)/resource/features/store/initialState';
|
||||
|
||||
import { useExplorerSelectionActions } from './useExplorerSelection';
|
||||
|
||||
describe('useExplorerSelectionActions', () => {
|
||||
beforeEach(() => {
|
||||
useResourceManagerStore.setState(initialState);
|
||||
});
|
||||
|
||||
it('should keep all-selection mode and store deselected ids as exclusions', () => {
|
||||
useResourceManagerStore.setState({ selectAllState: 'all', selectedFileIds: [] });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useExplorerSelectionActions([{ id: 'file-1' }, { id: 'file-2' }]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleItemSelection('file-1', false);
|
||||
});
|
||||
|
||||
expect(useResourceManagerStore.getState()).toMatchObject({
|
||||
selectAllState: 'all',
|
||||
selectedFileIds: ['file-1'],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleItemSelection('file-1', true);
|
||||
});
|
||||
|
||||
expect(useResourceManagerStore.getState()).toMatchObject({
|
||||
selectAllState: 'all',
|
||||
selectedFileIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should reselect excluded items on the current page without clearing cross-page selection', () => {
|
||||
useResourceManagerStore.setState({
|
||||
selectAllState: 'all',
|
||||
selectedFileIds: ['file-1', 'file-9'],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useExplorerSelectionActions([{ id: 'file-1' }, { id: 'file-2' }]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleSelectAll(true);
|
||||
});
|
||||
|
||||
expect(useResourceManagerStore.getState()).toMatchObject({
|
||||
selectAllState: 'all',
|
||||
selectedFileIds: ['file-9'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useEventCallback } from '@/hooks/useEventCallback';
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import {
|
||||
getExplorerSelectAllUiState,
|
||||
getExplorerSelectedCount,
|
||||
isExplorerItemSelected,
|
||||
} from '@/routes/(main)/resource/features/store/selectors';
|
||||
import { useFileStore } from '@/store/file';
|
||||
|
||||
interface ExplorerSelectionOptions {
|
||||
data: Array<{ id: string }>;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export const useExplorerSelectionSummary = ({ data, hasMore }: ExplorerSelectionOptions) => {
|
||||
const [selectAllState, selectedFileIds] = useResourceManagerStore((s) => [
|
||||
s.selectAllState,
|
||||
s.selectedFileIds,
|
||||
]);
|
||||
const total = useFileStore((s) => s.total);
|
||||
const selectedCount = useMemo(
|
||||
() => getExplorerSelectedCount({ selectAllState, selectedIds: selectedFileIds, total }),
|
||||
[selectAllState, selectedFileIds, total],
|
||||
);
|
||||
|
||||
const uiState = useMemo(
|
||||
() =>
|
||||
getExplorerSelectAllUiState({
|
||||
data,
|
||||
hasMore,
|
||||
selectAllState,
|
||||
selectedIds: selectedFileIds,
|
||||
}),
|
||||
[data, hasMore, selectAllState, selectedFileIds],
|
||||
);
|
||||
|
||||
return {
|
||||
...uiState,
|
||||
selectedCount,
|
||||
selectAllState,
|
||||
selectedFileIds,
|
||||
total,
|
||||
};
|
||||
};
|
||||
|
||||
export const useExplorerSelectionActions = (data: Array<{ id: string }>) => {
|
||||
const [
|
||||
clearSelectAllState,
|
||||
selectAllLoadedResources,
|
||||
selectAllResources,
|
||||
setSelectedFileIds,
|
||||
selectedFileIds,
|
||||
selectAllState,
|
||||
] = useResourceManagerStore((s) => [
|
||||
s.clearSelectAllState,
|
||||
s.selectAllLoadedResources,
|
||||
s.selectAllResources,
|
||||
s.setSelectedFileIds,
|
||||
s.selectedFileIds,
|
||||
s.selectAllState,
|
||||
]);
|
||||
|
||||
const handleSelectAll = useEventCallback((checked?: boolean) => {
|
||||
const store = useResourceManagerStore.getState();
|
||||
const allLoadedSelected =
|
||||
data.length > 0 &&
|
||||
data.every((item) =>
|
||||
isExplorerItemSelected({
|
||||
id: item.id,
|
||||
selectAllState: store.selectAllState,
|
||||
selectedIds: store.selectedFileIds,
|
||||
}),
|
||||
);
|
||||
|
||||
if (checked === false || (store.selectAllState !== 'all' && allLoadedSelected)) {
|
||||
clearSelectAllState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.selectAllState === 'all') {
|
||||
const loadedIds = new Set(data.map((item) => item.id));
|
||||
const nextExcludedIds = store.selectedFileIds.filter((id) => !loadedIds.has(id));
|
||||
|
||||
if (nextExcludedIds.length !== store.selectedFileIds.length) {
|
||||
setSelectedFileIds(nextExcludedIds);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
selectAllLoadedResources(data.map((item) => item.id));
|
||||
});
|
||||
|
||||
const handleSelectAllResources = useCallback(() => {
|
||||
selectAllResources();
|
||||
}, [selectAllResources]);
|
||||
|
||||
const toggleItemSelection = useCallback(
|
||||
(id: string, checked: boolean) => {
|
||||
const { selectAllState: currentSelectAllState, selectedFileIds: currentSelected } =
|
||||
useResourceManagerStore.getState();
|
||||
|
||||
if (currentSelectAllState === 'all') {
|
||||
if (checked) {
|
||||
if (!currentSelected.includes(id)) return;
|
||||
setSelectedFileIds(currentSelected.filter((item) => item !== id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSelected.includes(id)) return;
|
||||
setSelectedFileIds([...currentSelected, id]);
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelectAllState();
|
||||
|
||||
if (checked) {
|
||||
if (currentSelected.includes(id)) return;
|
||||
setSelectedFileIds([...currentSelected, id]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFileIds(currentSelected.filter((item) => item !== id));
|
||||
},
|
||||
[clearSelectAllState, setSelectedFileIds],
|
||||
);
|
||||
|
||||
return {
|
||||
clearSelectAllState,
|
||||
handleSelectAll,
|
||||
handleSelectAllResources,
|
||||
selectAllState,
|
||||
selectedFileIds,
|
||||
setSelectedFileIds,
|
||||
toggleItemSelection,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { type FileListItem } from '@/types/files';
|
||||
|
||||
/**
|
||||
* Hook to manage file selection with shift-click range selection support
|
||||
*/
|
||||
export const useFileSelection = (data?: FileListItem[]) => {
|
||||
const [selectFileIds, setSelectedFileIds] = useResourceManagerStore((s) => [
|
||||
s.selectedFileIds,
|
||||
s.setSelectedFileIds,
|
||||
]);
|
||||
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
// Handle selection change with shift-click support for range selection
|
||||
const handleSelectionChange = useCallback(
|
||||
(id: string, checked: boolean, shiftKey: boolean, clickedIndex: number) => {
|
||||
if (shiftKey && lastSelectedIndex !== null && selectFileIds.length > 0 && data) {
|
||||
const start = Math.min(lastSelectedIndex, clickedIndex);
|
||||
const end = Math.max(lastSelectedIndex, clickedIndex);
|
||||
const rangeIds = data.slice(start, end + 1).map((item) => item.id);
|
||||
|
||||
const prevSet = new Set(selectFileIds);
|
||||
rangeIds.forEach((rangeId) => prevSet.add(rangeId));
|
||||
setSelectedFileIds(Array.from(prevSet));
|
||||
} else {
|
||||
if (checked) {
|
||||
setSelectedFileIds([...selectFileIds, id]);
|
||||
} else {
|
||||
setSelectedFileIds(selectFileIds.filter((item) => item !== id));
|
||||
}
|
||||
}
|
||||
setLastSelectedIndex(clickedIndex);
|
||||
},
|
||||
[lastSelectedIndex, selectFileIds, data, setSelectedFileIds],
|
||||
);
|
||||
|
||||
// Clean up invalid selections when data changes
|
||||
useEffect(() => {
|
||||
if (data && selectFileIds.length > 0) {
|
||||
const validFileIds = new Set(data.map((item) => item?.id).filter(Boolean));
|
||||
const filteredSelection = selectFileIds.filter((id) => validFileIds.has(id));
|
||||
if (filteredSelection.length !== selectFileIds.length) {
|
||||
setSelectedFileIds(filteredSelection);
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Reset last selected index when all selections are cleared
|
||||
useEffect(() => {
|
||||
if (selectFileIds.length === 0) {
|
||||
setLastSelectedIndex(null);
|
||||
}
|
||||
}, [selectFileIds.length]);
|
||||
|
||||
return {
|
||||
handleSelectionChange,
|
||||
selectFileIds,
|
||||
setSelectedFileIds,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { initialState } from '@/routes/(main)/resource/features/store/initialState';
|
||||
import { FilesTabs } from '@/types/files';
|
||||
|
||||
import { useResetSelectionOnQueryChange } from './useResetSelectionOnQueryChange';
|
||||
|
||||
describe('useResetSelectionOnQueryChange', () => {
|
||||
beforeEach(() => {
|
||||
useResourceManagerStore.setState(initialState);
|
||||
});
|
||||
|
||||
it('should clear all-selection mode when the search query changes', () => {
|
||||
const { rerender } = renderHook(
|
||||
(props: { searchQuery: string | null }) =>
|
||||
useResetSelectionOnQueryChange({
|
||||
category: FilesTabs.All,
|
||||
currentFolderSlug: 'folder-a',
|
||||
libraryId: undefined,
|
||||
searchQuery: props.searchQuery,
|
||||
}),
|
||||
{
|
||||
initialProps: { searchQuery: null as string | null },
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
useResourceManagerStore.setState({
|
||||
selectAllState: 'all',
|
||||
selectedFileIds: ['file-1'],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
rerender({ searchQuery: 'report' });
|
||||
});
|
||||
|
||||
expect(useResourceManagerStore.getState()).toMatchObject({
|
||||
selectAllState: 'none',
|
||||
selectedFileIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear selection when the folder changes', () => {
|
||||
const { rerender } = renderHook(
|
||||
(props: { currentFolderSlug: string | null }) =>
|
||||
useResetSelectionOnQueryChange({
|
||||
category: FilesTabs.All,
|
||||
currentFolderSlug: props.currentFolderSlug,
|
||||
libraryId: undefined,
|
||||
searchQuery: null,
|
||||
}),
|
||||
{
|
||||
initialProps: { currentFolderSlug: 'folder-a' },
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
useResourceManagerStore.setState({
|
||||
selectAllState: 'loaded',
|
||||
selectedFileIds: ['file-1', 'file-2'],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
rerender({ currentFolderSlug: 'folder-b' });
|
||||
});
|
||||
|
||||
expect(useResourceManagerStore.getState()).toMatchObject({
|
||||
selectAllState: 'none',
|
||||
selectedFileIds: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import type { FilesTabs } from '@/types/files';
|
||||
|
||||
interface ResetSelectionOnQueryChangeOptions {
|
||||
category: FilesTabs;
|
||||
currentFolderSlug?: string | null;
|
||||
libraryId?: string;
|
||||
searchQuery: string | null;
|
||||
}
|
||||
|
||||
export const useResetSelectionOnQueryChange = ({
|
||||
category,
|
||||
currentFolderSlug,
|
||||
libraryId,
|
||||
searchQuery,
|
||||
}: ResetSelectionOnQueryChangeOptions) => {
|
||||
const clearSelectAllState = useResourceManagerStore((s) => s.clearSelectAllState);
|
||||
|
||||
useEffect(() => {
|
||||
clearSelectAllState();
|
||||
}, [category, clearSelectAllState, currentFolderSlug, libraryId, searchQuery]);
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { useFolderPath } from '@/routes/(main)/resource/features/hooks/useFolderPath';
|
||||
import { useResourceManagerUrlSync } from '@/routes/(main)/resource/features/hooks/useResourceManagerUrlSync';
|
||||
|
|
@ -9,13 +9,14 @@ import { useResourceManagerStore } from '@/routes/(main)/resource/features/store
|
|||
import { sortFileList } from '@/routes/(main)/resource/features/store/selectors';
|
||||
import { useFetchResources, useResourceStore } from '@/store/file/slices/resource/hooks';
|
||||
|
||||
import { KnowledgeBaseListProvider } from '../KnowledgeBaseListProvider';
|
||||
import EmptyPlaceholder from './EmptyPlaceholder';
|
||||
import Header from './Header';
|
||||
import { useResetSelectionOnQueryChange } from './hooks/useResetSelectionOnQueryChange';
|
||||
import ListView from './ListView';
|
||||
import MasonryView from './MasonryView';
|
||||
import SearchResultsOverlay from './SearchResultsOverlay';
|
||||
import { useCheckTaskStatus } from './useCheckTaskStatus';
|
||||
import { useResourceExplorer } from './useResourceExplorer';
|
||||
|
||||
/**
|
||||
* Explore resource items in a library
|
||||
|
|
@ -30,18 +31,9 @@ const ResourceExplorer = memo(() => {
|
|||
useResourceManagerUrlSync();
|
||||
|
||||
// Get state from Resource Manager store
|
||||
const [libraryId, category, viewMode, searchQuery, setSelectedFileIds, sorter, sortType] =
|
||||
useResourceManagerStore((s) => [
|
||||
s.libraryId,
|
||||
s.category,
|
||||
s.viewMode,
|
||||
s.searchQuery,
|
||||
s.setSelectedFileIds,
|
||||
s.sorter,
|
||||
s.sortType,
|
||||
]);
|
||||
|
||||
// searchQuery is still subscribed above for selection-clearing effect below
|
||||
const [libraryId, category, viewMode, searchQuery, sorter, sortType] = useResourceManagerStore(
|
||||
(s) => [s.libraryId, s.category, s.viewMode, s.searchQuery, s.sorter, s.sortType],
|
||||
);
|
||||
|
||||
// Get folder path for empty state check
|
||||
const { currentFolderSlug } = useFolderPath();
|
||||
|
|
@ -87,30 +79,35 @@ const ResourceExplorer = memo(() => {
|
|||
// Check task status
|
||||
useCheckTaskStatus(data);
|
||||
|
||||
// Initialize folder/file navigation effects (still need hook for complex effects)
|
||||
useResourceExplorer({ category, libraryId });
|
||||
|
||||
// Clear selections when category/library/search changes
|
||||
useEffect(() => {
|
||||
setSelectedFileIds([]);
|
||||
}, [category, libraryId, searchQuery, setSelectedFileIds]);
|
||||
useResetSelectionOnQueryChange({
|
||||
category,
|
||||
currentFolderSlug,
|
||||
libraryId,
|
||||
searchQuery,
|
||||
});
|
||||
|
||||
const showEmptyStatus = !isLoading && !isValidating && data?.length === 0 && !currentFolderSlug;
|
||||
|
||||
return (
|
||||
<Flexbox height={'100%'}>
|
||||
<Header />
|
||||
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||
{showEmptyStatus ? (
|
||||
<EmptyPlaceholder />
|
||||
) : viewMode === 'list' ? (
|
||||
<ListView />
|
||||
) : (
|
||||
<MasonryView />
|
||||
)}
|
||||
<SearchResultsOverlay />
|
||||
</div>
|
||||
</Flexbox>
|
||||
<KnowledgeBaseListProvider>
|
||||
<Flexbox height={'100%'}>
|
||||
<Header />
|
||||
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||
{showEmptyStatus ? (
|
||||
<EmptyPlaceholder />
|
||||
) : viewMode === 'list' ? (
|
||||
<ListView isLoading={isLoading} isValidating={isValidating} queryParams={queryParams} />
|
||||
) : (
|
||||
<MasonryView
|
||||
isLoading={isLoading}
|
||||
isValidating={isValidating}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
)}
|
||||
<SearchResultsOverlay />
|
||||
</div>
|
||||
</Flexbox>
|
||||
</KnowledgeBaseListProvider>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,39 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { revalidateResources } from '@/store/file/slices/resource/hooks';
|
||||
import { resourceService } from '@/services/resource';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { AsyncTaskStatus } from '@/types/asyncTask';
|
||||
import { type FileListItem } from '@/types/files';
|
||||
|
||||
export const useCheckTaskStatus = (data: FileListItem[] | undefined) => {
|
||||
const hasProcessingChunkTask = data?.some(
|
||||
(item) => item.chunkingStatus === AsyncTaskStatus.Processing,
|
||||
);
|
||||
const hasProcessingEmbeddingTask = data?.some(
|
||||
(item) => item.embeddingStatus === AsyncTaskStatus.Processing,
|
||||
);
|
||||
|
||||
const isProcessing = hasProcessingChunkTask || hasProcessingEmbeddingTask;
|
||||
const processingFileIds =
|
||||
data
|
||||
?.filter(
|
||||
(item) =>
|
||||
item.sourceType === 'file' &&
|
||||
(item.chunkingStatus === AsyncTaskStatus.Processing ||
|
||||
item.embeddingStatus === AsyncTaskStatus.Processing),
|
||||
)
|
||||
.map((item) => item.id) ?? [];
|
||||
const processingKey = processingFileIds.join(',');
|
||||
|
||||
// Poll every 5s to check if chunking/embedding status has changed
|
||||
useEffect(() => {
|
||||
if (!isProcessing) return;
|
||||
if (processingFileIds.length === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
// Re-fetch with the same query params used for initial load
|
||||
revalidateResources();
|
||||
void resourceService
|
||||
.getResourceStatusesByIds(processingFileIds)
|
||||
.then((items) => {
|
||||
useFileStore.getState().patchLocalResourceStatuses(items);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to sync knowledge item statuses:', error);
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [isProcessing]);
|
||||
}, [processingKey]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,214 +0,0 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useAddFilesToKnowledgeBaseModal } from '@/features/LibraryModal';
|
||||
import { useFolderPath } from '@/routes/(main)/resource/features/hooks/useFolderPath';
|
||||
import {
|
||||
useResourceManagerFetchFolderBreadcrumb,
|
||||
useResourceManagerFetchKnowledgeItem,
|
||||
useResourceManagerFetchKnowledgeItems,
|
||||
useResourceManagerStore,
|
||||
} from '@/routes/(main)/resource/features/store';
|
||||
import { type MultiSelectActionType } from '@/routes/(main)/resource/features/store/action';
|
||||
import { selectors, sortFileList } from '@/routes/(main)/resource/features/store/selectors';
|
||||
import { fileManagerSelectors, useFileStore } from '@/store/file';
|
||||
import { type FilesTabs } from '@/types/files';
|
||||
|
||||
import { useFileSelection } from './hooks/useFileSelection';
|
||||
import { useCheckTaskStatus } from './useCheckTaskStatus';
|
||||
|
||||
interface UseFileExplorerProps {
|
||||
category?: FilesTabs;
|
||||
libraryId?: string;
|
||||
}
|
||||
|
||||
export const useResourceExplorer = ({
|
||||
category: categoryProp,
|
||||
libraryId,
|
||||
}: UseFileExplorerProps) => {
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
// Get state from Resource Manager store
|
||||
const [
|
||||
viewMode,
|
||||
currentViewItemId,
|
||||
isTransitioning,
|
||||
isMasonryReady,
|
||||
searchQuery,
|
||||
setCurrentFolderId,
|
||||
setIsTransitioning,
|
||||
setIsMasonryReady,
|
||||
handleBackToList,
|
||||
onActionClick,
|
||||
pendingRenameItemId,
|
||||
loadMoreKnowledgeItems,
|
||||
fileListHasMore,
|
||||
sorter,
|
||||
sortType,
|
||||
] = useResourceManagerStore((s) => [
|
||||
s.viewMode,
|
||||
s.currentViewItemId,
|
||||
s.isTransitioning,
|
||||
s.isMasonryReady,
|
||||
s.searchQuery,
|
||||
s.setCurrentFolderId,
|
||||
s.setIsTransitioning,
|
||||
s.setIsMasonryReady,
|
||||
s.handleBackToList,
|
||||
s.onActionClick,
|
||||
s.pendingRenameItemId,
|
||||
s.loadMoreKnowledgeItems,
|
||||
s.fileListHasMore,
|
||||
s.sorter,
|
||||
s.sortType,
|
||||
]);
|
||||
|
||||
const categoryFromStore = useResourceManagerStore((s) => s.category);
|
||||
const category = categoryProp ?? categoryFromStore;
|
||||
|
||||
// Folder navigation
|
||||
const { currentFolderSlug } = useFolderPath();
|
||||
|
||||
// Current file
|
||||
const { data: fetchedCurrentFile } = useResourceManagerFetchKnowledgeItem(currentViewItemId);
|
||||
const currentFile =
|
||||
useFileStore(fileManagerSelectors.getFileById(currentViewItemId)) || fetchedCurrentFile;
|
||||
|
||||
// Folder operations
|
||||
const { data: folderBreadcrumb } = useResourceManagerFetchFolderBreadcrumb(currentFolderSlug);
|
||||
|
||||
// Fetch data with SWR
|
||||
const { data: rawData, isLoading } = useResourceManagerFetchKnowledgeItems({
|
||||
category,
|
||||
knowledgeBaseId: libraryId,
|
||||
parentId: currentFolderSlug || null,
|
||||
q: searchQuery ?? undefined,
|
||||
showFilesInKnowledgeBase: false,
|
||||
});
|
||||
|
||||
// Sort data using current sort settings
|
||||
const data = sortFileList(rawData, sorter, sortType);
|
||||
|
||||
const { handleSelectionChange, selectFileIds, setSelectedFileIds } = useFileSelection(data);
|
||||
|
||||
useCheckTaskStatus(data);
|
||||
|
||||
// Get modal handler for knowledge base operations
|
||||
const { open: openAddModal } = useAddFilesToKnowledgeBaseModal();
|
||||
|
||||
// Wrap onActionClick to handle modal operations that need React hooks
|
||||
const handleActionClick = useCallback(
|
||||
async (type: MultiSelectActionType) => {
|
||||
// Handle modal-based actions here (can't be in store due to React hooks)
|
||||
if (type === 'addToKnowledgeBase') {
|
||||
openAddModal({
|
||||
fileIds: selectFileIds,
|
||||
onClose: () => setSelectedFileIds([]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'moveToOtherKnowledgeBase') {
|
||||
openAddModal({
|
||||
fileIds: selectFileIds,
|
||||
knowledgeBaseId: libraryId,
|
||||
onClose: () => setSelectedFileIds([]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate other actions to store
|
||||
await onActionClick(type);
|
||||
},
|
||||
[onActionClick, openAddModal, selectFileIds, setSelectedFileIds, libraryId],
|
||||
);
|
||||
|
||||
// Wrap handleBackToList to also update URL params
|
||||
const handleBackToListWithUrl = useCallback(() => {
|
||||
handleBackToList();
|
||||
setSearchParams((prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.delete('file');
|
||||
return newParams;
|
||||
});
|
||||
}, [handleBackToList, setSearchParams]);
|
||||
|
||||
// Effects - Folder navigation
|
||||
useEffect(() => {
|
||||
if (!currentFolderSlug) {
|
||||
setCurrentFolderId(null);
|
||||
} else if (folderBreadcrumb && folderBreadcrumb.length > 0) {
|
||||
const currentFolder = folderBreadcrumb.at(-1);
|
||||
setCurrentFolderId(currentFolder?.id ?? null);
|
||||
}
|
||||
}, [currentFolderSlug, folderBreadcrumb, setCurrentFolderId]);
|
||||
|
||||
// Handle view mode transition effects
|
||||
useEffect(() => {
|
||||
if (viewMode === 'masonry') {
|
||||
setIsTransitioning(true);
|
||||
setIsMasonryReady(false);
|
||||
}
|
||||
}, [viewMode, setIsTransitioning, setIsMasonryReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTransitioning && data) {
|
||||
requestAnimationFrame(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
}, [isTransitioning, data, setIsTransitioning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'masonry' && data && !isLoading && !isTransitioning) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsMasonryReady(true);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (viewMode === 'list') {
|
||||
setIsMasonryReady(false);
|
||||
}
|
||||
}, [viewMode, data, isLoading, isTransitioning, setIsMasonryReady]);
|
||||
|
||||
const showEmptyStatus = !isLoading && data?.length === 0 && !currentFolderSlug;
|
||||
const isFilePreviewMode = useResourceManagerStore(selectors.isFilePreviewMode);
|
||||
|
||||
return {
|
||||
// Data
|
||||
category,
|
||||
currentFile,
|
||||
currentFolderSlug,
|
||||
currentViewItemId,
|
||||
data,
|
||||
// Handlers
|
||||
handleBackToList: handleBackToListWithUrl,
|
||||
|
||||
handleSelectionChange,
|
||||
|
||||
hasMore: fileListHasMore,
|
||||
|
||||
isFilePreviewMode,
|
||||
|
||||
isLoading,
|
||||
|
||||
// State
|
||||
isMasonryReady,
|
||||
|
||||
isTransitioning,
|
||||
|
||||
// Pagination
|
||||
loadMoreKnowledgeItems,
|
||||
|
||||
onActionClick: handleActionClick,
|
||||
|
||||
pendingRenameItemId,
|
||||
|
||||
selectFileIds,
|
||||
setSelectedFileIds,
|
||||
showEmptyStatus,
|
||||
viewMode,
|
||||
};
|
||||
};
|
||||
|
|
@ -4,7 +4,7 @@ import { CaretDownFilled } from '@ant-design/icons';
|
|||
import { ActionIcon, Flexbox, Icon } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { FolderIcon, FolderOpenIcon } from 'lucide-react';
|
||||
import * as motion from 'motion/react-m';
|
||||
import * as m from 'motion/react-m';
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
|
|
@ -89,7 +89,7 @@ export const FolderTreeItemComponent = memo<FolderTreeItemProps>(
|
|||
style={{ paddingInlineStart: level * 16 + 8 }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ rotate: isExpanded ? 0 : -90 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
|
|
@ -101,7 +101,7 @@ export const FolderTreeItemComponent = memo<FolderTreeItemProps>(
|
|||
handleToggle();
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
|
|
@ -126,7 +126,7 @@ export const FolderTreeItemComponent = memo<FolderTreeItemProps>(
|
|||
</Flexbox>
|
||||
|
||||
{isExpanded && item.children && item.children.length > 0 && (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
|
|
@ -147,7 +147,7 @@ export const FolderTreeItemComponent = memo<FolderTreeItemProps>(
|
|||
/>
|
||||
))}
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { message } from '@/components/AntdStaticMethods';
|
||||
import GuideModal from '@/components/GuideModal';
|
||||
import GuideVideo from '@/components/GuideVideo';
|
||||
import { useCurrentFolderId } from '@/routes/(main)/resource/features/hooks/useCurrentFolderId';
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { FilesTabs } from '@/types/files';
|
||||
|
|
@ -46,28 +47,21 @@ const AddButton = () => {
|
|||
const uploadFolderWithStructure = useFileStore((s) => s.uploadFolderWithStructure);
|
||||
const createResourceAndSync = useFileStore((s) => s.createResourceAndSync);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const currentFolderId = useCurrentFolderId();
|
||||
|
||||
// TODO: Migrate Notion import to use createResource
|
||||
// Keep old functions temporarily for components not yet migrated
|
||||
const createDocument = useFileStore((s) => s.createDocument);
|
||||
|
||||
const [
|
||||
libraryId,
|
||||
category,
|
||||
currentFolderId,
|
||||
setCategory,
|
||||
setCurrentViewItemId,
|
||||
setMode,
|
||||
setPendingRenameItemId,
|
||||
] = useResourceManagerStore((s) => [
|
||||
s.libraryId,
|
||||
s.category,
|
||||
s.currentFolderId,
|
||||
s.setCategory,
|
||||
s.setCurrentViewItemId,
|
||||
s.setMode,
|
||||
s.setPendingRenameItemId,
|
||||
]);
|
||||
const [libraryId, category, setCategory, setCurrentViewItemId, setMode, setPendingRenameItemId] =
|
||||
useResourceManagerStore((s) => [
|
||||
s.libraryId,
|
||||
s.category,
|
||||
s.setCategory,
|
||||
s.setCurrentViewItemId,
|
||||
s.setMode,
|
||||
s.setPendingRenameItemId,
|
||||
]);
|
||||
|
||||
const handleOpenPageEditor = useCallback(async () => {
|
||||
// Navigate to "All" category first if not already there
|
||||
|
|
@ -167,10 +161,6 @@ const AddButton = () => {
|
|||
createDocument,
|
||||
currentFolderId,
|
||||
libraryId,
|
||||
refetchResources: async () => {
|
||||
const { revalidateResources } = await import('@/store/file/slices/resource/hooks');
|
||||
await revalidateResources();
|
||||
},
|
||||
t,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface UseNotionImportOptions {
|
|||
createDocument: DocumentAction['createDocument'];
|
||||
currentFolderId?: string | null;
|
||||
libraryId?: string | null;
|
||||
refetchResources: () => Promise<void>;
|
||||
refetchResources?: () => Promise<void>;
|
||||
t: TFunction<'file'>;
|
||||
}
|
||||
|
||||
|
|
@ -172,8 +172,7 @@ const useNotionImport = ({
|
|||
);
|
||||
}
|
||||
|
||||
// Refetch resources to show imported documents
|
||||
await refetchResources();
|
||||
await refetchResources?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to import Notion export:', error);
|
||||
const { message } = await import('antd');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
'use client';
|
||||
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, memo, use, useMemo } from 'react';
|
||||
|
||||
import { useKnowledgeBaseStore } from '@/store/library';
|
||||
import type { KnowledgeBaseItem } from '@/types/knowledgeBase';
|
||||
|
||||
const KnowledgeBaseListContext = createContext<KnowledgeBaseItem[] | null>(null);
|
||||
|
||||
export const KnowledgeBaseListProvider = memo<PropsWithChildren>(({ children }) => {
|
||||
const useFetchKnowledgeBaseList = useKnowledgeBaseStore((s) => s.useFetchKnowledgeBaseList);
|
||||
const { data } = useFetchKnowledgeBaseList();
|
||||
|
||||
const knowledgeBases = useMemo(() => data ?? [], [data]);
|
||||
|
||||
return (
|
||||
<KnowledgeBaseListContext value={knowledgeBases}>
|
||||
{children}
|
||||
</KnowledgeBaseListContext>
|
||||
);
|
||||
});
|
||||
|
||||
KnowledgeBaseListProvider.displayName = 'KnowledgeBaseListProvider';
|
||||
|
||||
export const useKnowledgeBaseListContext = () => {
|
||||
const context = use(KnowledgeBaseListContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useKnowledgeBaseListContext must be used within KnowledgeBaseListProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
|
@ -5,7 +5,7 @@ import { ActionIcon, Block, Flexbox, Icon, showContextMenu, stopPropagation } fr
|
|||
import { App, Input } from 'antd';
|
||||
import { cx } from 'antd-style';
|
||||
import { FileText, FolderIcon, FolderOpenIcon } from 'lucide-react';
|
||||
import * as motion from 'motion/react-m';
|
||||
import * as m from 'motion/react-m';
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ import { PAGE_FILE_TYPE } from '@/features/ResourceManager/constants';
|
|||
import {
|
||||
getTransparentDragImage,
|
||||
useDragActive,
|
||||
useDragState,
|
||||
useSetCurrentDrag,
|
||||
} from '@/routes/(main)/resource/features/DndContextWrapper';
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { useFileStore } from '@/store/file';
|
||||
|
|
@ -135,7 +135,7 @@ export const HierarchyNode = memo<HierarchyNodeProps>(
|
|||
});
|
||||
|
||||
const isDragActive = useDragActive();
|
||||
const { setCurrentDrag } = useDragState();
|
||||
const setCurrentDrag = useSetCurrentDrag();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
|
||||
|
|
@ -264,7 +264,7 @@ export const HierarchyNode = memo<HierarchyNodeProps>(
|
|||
{isLoading ? (
|
||||
<ActionIcon spin icon={LoadingOutlined as any} size={'small'} style={{ width: 20 }} />
|
||||
) : (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ rotate: isExpanded ? 0 : -90 }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
|
|
@ -278,7 +278,7 @@ export const HierarchyNode = memo<HierarchyNodeProps>(
|
|||
handleToggle();
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
<Flexbox
|
||||
horizontal
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { fileService } from '@/services/file';
|
|||
import { useFileStore } from '@/store/file';
|
||||
import { type ResourceQueryParams } from '@/types/resource';
|
||||
|
||||
import { KnowledgeBaseListProvider } from '../KnowledgeBaseListProvider';
|
||||
import { HierarchyNode } from './HierarchyNode';
|
||||
import TreeSkeleton from './TreeSkeleton';
|
||||
import {
|
||||
|
|
@ -368,28 +369,30 @@ const LibraryHierarchy = memo(() => {
|
|||
: currentFolderSlug;
|
||||
|
||||
return (
|
||||
<Flexbox paddingInline={4} style={{ height: '100%' }}>
|
||||
<VList
|
||||
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
{visibleNodes.map(({ item, key, level }) => (
|
||||
<div key={key} style={{ paddingBottom: 2 }}>
|
||||
<HierarchyNode
|
||||
expandedFolders={expandedFolders}
|
||||
folderChildrenCache={folderChildrenCache}
|
||||
item={item}
|
||||
level={level}
|
||||
loadingFolders={loadingFolders}
|
||||
selectedKey={selectedKey}
|
||||
updateKey={updateKey}
|
||||
onLoadFolder={handleLoadFolder}
|
||||
onToggleFolder={handleToggleFolder}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</VList>
|
||||
</Flexbox>
|
||||
<KnowledgeBaseListProvider>
|
||||
<Flexbox paddingInline={4} style={{ height: '100%' }}>
|
||||
<VList
|
||||
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
{visibleNodes.map(({ item, key, level }) => (
|
||||
<div key={key} style={{ paddingBottom: 2 }}>
|
||||
<HierarchyNode
|
||||
expandedFolders={expandedFolders}
|
||||
folderChildrenCache={folderChildrenCache}
|
||||
item={item}
|
||||
level={level}
|
||||
loadingFolders={loadingFolders}
|
||||
selectedKey={selectedKey}
|
||||
updateKey={updateKey}
|
||||
onLoadFolder={handleLoadFolder}
|
||||
onToggleFolder={handleToggleFolder}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</VList>
|
||||
</Flexbox>
|
||||
</KnowledgeBaseListProvider>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@ import { ActionIcon, Center, Flexbox, Icon, Text } from '@lobehub/ui';
|
|||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { UploadIcon, XIcon } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { AnimatePresence, m } from 'motion/react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fileManagerSelectors, useFileStore } from '@/store/file';
|
||||
import { convertAlphaToSolid } from '@/utils/colorUtils';
|
||||
|
||||
import Item from './Item';
|
||||
|
||||
|
|
@ -60,12 +59,26 @@ const UploadDock = memo(() => {
|
|||
const setExpand = useFileStore((s) => s.setUploadDockExpanded);
|
||||
const totalUploadingProgress = useFileStore(fileManagerSelectors.overviewUploadingProgress);
|
||||
const fileList = useFileStore(fileManagerSelectors.dockFileList, isEqual);
|
||||
const cancelUploads = useFileStore((s) => s.cancelUploads);
|
||||
const overviewUploadingStatus = useFileStore(
|
||||
fileManagerSelectors.overviewUploadingStatus,
|
||||
isEqual,
|
||||
);
|
||||
const isUploading = overviewUploadingStatus === 'uploading';
|
||||
|
||||
const hasCancellableUploads = useMemo(
|
||||
() => fileList.some((item) => item.status === 'uploading' || item.status === 'pending'),
|
||||
[fileList],
|
||||
);
|
||||
|
||||
const cancelAllActiveUploads = useCallback(() => {
|
||||
cancelUploads(
|
||||
fileList
|
||||
.filter((item) => item.status === 'uploading' || item.status === 'pending')
|
||||
.map((item) => item.id),
|
||||
);
|
||||
}, [cancelUploads, fileList]);
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (overviewUploadingStatus) {
|
||||
case 'success': {
|
||||
|
|
@ -111,35 +124,44 @@ const UploadDock = memo(() => {
|
|||
onClick={() => {
|
||||
setExpand(!expand);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = convertAlphaToSolid(
|
||||
cssVar.colorFillTertiary,
|
||||
cssVar.colorBgContainer,
|
||||
);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = cssVar.colorBgContainer;
|
||||
}}
|
||||
>
|
||||
<Flexbox horizontal align={'center'} className={styles.title} gap={16}>
|
||||
{icon}
|
||||
{t(`uploadDock.uploadStatus.${overviewUploadingStatus}`)} ·{' '}
|
||||
{t('uploadDock.totalCount', { count })}
|
||||
</Flexbox>
|
||||
{!isUploading && (
|
||||
<ActionIcon
|
||||
icon={XIcon}
|
||||
onClick={() => {
|
||||
setShow(false);
|
||||
dispatchDockFileList({ ids: fileList.map((item) => item.id), type: 'removeFiles' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
gap={12}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{hasCancellableUploads && (
|
||||
<Text
|
||||
style={{ cursor: 'pointer', flexShrink: 0, fontSize: 13 }}
|
||||
type={'secondary'}
|
||||
onClick={cancelAllActiveUploads}
|
||||
>
|
||||
{t('uploadDock.header.cancelAll')}
|
||||
</Text>
|
||||
)}
|
||||
{!isUploading && (
|
||||
<ActionIcon
|
||||
icon={XIcon}
|
||||
onClick={() => {
|
||||
setShow(false);
|
||||
dispatchDockFileList({ ids: fileList.map((item) => item.id), type: 'removeFiles' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{expand ? (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ height: 400, opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
|
|
@ -148,7 +170,6 @@ const UploadDock = memo(() => {
|
|||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
<Flexbox
|
||||
justify={'space-between'}
|
||||
style={{
|
||||
background: cssVar.colorBgContainer,
|
||||
borderBottomLeftRadius: 8,
|
||||
|
|
@ -156,27 +177,34 @@ const UploadDock = memo(() => {
|
|||
height: 400,
|
||||
}}
|
||||
>
|
||||
<Flexbox gap={8} paddingBlock={8} style={{ overflowY: 'scroll' }}>
|
||||
<Flexbox
|
||||
flex={1}
|
||||
gap={8}
|
||||
paddingBlock={8}
|
||||
style={{ minHeight: 0, overflowY: 'scroll' }}
|
||||
>
|
||||
{fileList.map((item) => (
|
||||
<Item key={item.id} {...item} />
|
||||
))}
|
||||
</Flexbox>
|
||||
<Center style={{ height: 40, minHeight: 40 }}>
|
||||
<Text
|
||||
style={{ cursor: 'pointer' }}
|
||||
type={'secondary'}
|
||||
onClick={() => {
|
||||
setExpand(false);
|
||||
}}
|
||||
>
|
||||
{t('uploadDock.body.collapse')}
|
||||
</Text>
|
||||
</Center>
|
||||
{isUploading && (
|
||||
<Center style={{ flexShrink: 0, height: 40, minHeight: 40 }}>
|
||||
<Text
|
||||
style={{ cursor: 'pointer' }}
|
||||
type={'secondary'}
|
||||
onClick={() => {
|
||||
setExpand(false);
|
||||
}}
|
||||
>
|
||||
{t('uploadDock.body.collapse')}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
) : (
|
||||
overviewUploadingStatus !== 'pending' && (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ opacity: 1, scaleY: 1 }}
|
||||
exit={{ opacity: 0, scaleY: 0 }}
|
||||
initial={{ opacity: 0, scaleY: 0 }}
|
||||
|
|
@ -196,7 +224,7 @@ const UploadDock = memo(() => {
|
|||
insetInlineEnd: `${100 - totalUploadingProgress}%`,
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useSearchParams } from 'react-router-dom';
|
|||
import DragUploadZone from '@/components/DragUploadZone';
|
||||
import { PageEditor } from '@/features/PageEditor';
|
||||
import dynamic from '@/libs/next/dynamic';
|
||||
import { useCurrentFolderId } from '@/routes/(main)/resource/features/hooks/useCurrentFolderId';
|
||||
import { useResourceManagerStore } from '@/routes/(main)/resource/features/store';
|
||||
import { documentService } from '@/services/document';
|
||||
import { useFileStore } from '@/store/file';
|
||||
|
|
@ -59,12 +60,12 @@ export type ResourceManagerMode = 'editor' | 'explorer' | 'page';
|
|||
const ResourceManager = memo(() => {
|
||||
const theme = useTheme();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const [mode, currentViewItemId, libraryId, currentFolderId, setMode, setCurrentViewItemId] =
|
||||
const currentFolderId = useCurrentFolderId();
|
||||
const [mode, currentViewItemId, libraryId, setMode, setCurrentViewItemId] =
|
||||
useResourceManagerStore((s) => [
|
||||
s.mode,
|
||||
s.currentViewItemId,
|
||||
s.libraryId,
|
||||
s.currentFolderId,
|
||||
s.setMode,
|
||||
s.setCurrentViewItemId,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import isToday from 'dayjs/plugin/isToday';
|
|||
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { enableMapSet } from 'immer';
|
||||
import { enableMapSet, enablePatches } from 'immer';
|
||||
import { scan } from 'react-scan';
|
||||
|
||||
import { isChunkLoadError, notifyChunkError } from '@/utils/chunkError';
|
||||
|
||||
enablePatches();
|
||||
enableMapSet();
|
||||
|
||||
// Dayjs plugins - extend once at app init to avoid duplicate extensions in components
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { ConfigProvider, FontLoader, ThemeProvider } from '@lobehub/ui';
|
|||
import { message as antdMessage } from 'antd';
|
||||
import { AppConfigContext } from 'antd/es/app/context';
|
||||
import { createStaticStyles, cx, useTheme } from 'antd-style';
|
||||
import * as motion from 'motion/react-m';
|
||||
import * as m from 'motion/react-m';
|
||||
import { type ReactNode } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ const AppTheme = memo<AppThemeProps>(
|
|||
<AntdStaticMethods />
|
||||
<ConfigProvider
|
||||
locale={uiLocale}
|
||||
motion={motion}
|
||||
motion={m}
|
||||
resources={uiResources}
|
||||
config={{
|
||||
aAs: Link,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export default {
|
|||
'You are about to delete this file. Once deleted, it cannot be recovered. Please confirm your action.',
|
||||
'FileManager.actions.confirmDeleteFolder':
|
||||
'You are about to delete this folder and all of its contents. This action cannot be undone. Please confirm your decision.',
|
||||
'FileManager.actions.confirmDeleteAllFiles':
|
||||
'You are about to delete all results in the current view. Once deleted, they cannot be recovered. Please confirm your action.',
|
||||
'FileManager.actions.confirmDeleteMultiFiles':
|
||||
'You are about to delete the selected {{count}} files. Once deleted, they cannot be recovered. Please confirm your action.',
|
||||
'FileManager.actions.confirmRemoveFromLibrary':
|
||||
|
|
@ -57,7 +59,12 @@ export default {
|
|||
'FileManager.title.createdAt': 'Created At',
|
||||
'FileManager.title.size': 'Size',
|
||||
'FileManager.title.title': 'File',
|
||||
'FileManager.total.allSelectedCount': 'All {{count}} items are selected.',
|
||||
'FileManager.total.allSelectedFallback': 'All results are selected.',
|
||||
'FileManager.total.fileCount': 'Total {{count}} items',
|
||||
'FileManager.total.loadedSelectedCount': 'Selected {{count}} loaded items.',
|
||||
'FileManager.total.selectAll': 'Select all {{count}} items',
|
||||
'FileManager.total.selectAllFallback': 'Select all items',
|
||||
'FileManager.total.selectedCount': 'Selected {{count}} items',
|
||||
'FileManager.view.list': 'List View',
|
||||
'FileManager.view.masonry': 'Grid View',
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ export default {
|
|||
'title': 'Resources',
|
||||
'toggleLeftPanel': 'Show/Hide Left Panel',
|
||||
'uploadDock.body.collapse': 'Collapse',
|
||||
'uploadDock.header.cancelAll': 'Cancel all',
|
||||
'uploadDock.body.item.cancel': 'Cancel',
|
||||
'uploadDock.body.item.cancelled': 'Cancelled',
|
||||
'uploadDock.body.item.done': 'Uploaded',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { HashIcon, MessageSquareDashed } from 'lucide-react';
|
||||
import { AnimatePresence, m as motion } from 'motion/react';
|
||||
import { AnimatePresence, m } from 'motion/react';
|
||||
import { memo, Suspense, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
|||
<span className={styles.dotContainer} style={{ width: hasUnread ? 18 : 0 }}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{hasUnread && (
|
||||
<motion.div
|
||||
<m.div
|
||||
className={styles.neonDotWrapper}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{
|
||||
|
|
@ -136,7 +136,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
|||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
<m.span
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
|
|
@ -160,7 +160,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
|||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Flexbox } from '@lobehub/ui';
|
||||
import { AnimatePresence, m as motion } from 'motion/react';
|
||||
import { AnimatePresence, m } from 'motion/react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone';
|
||||
|
|
@ -126,7 +126,7 @@ const InputArea = () => {
|
|||
</div>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{showSuggestQuestions && (
|
||||
<motion.div
|
||||
<m.div
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, y: 8 }}
|
||||
initial={{ opacity: 0, scale: 0.98, y: 8 }}
|
||||
|
|
@ -141,7 +141,7 @@ const InputArea = () => {
|
|||
<SuggestQuestions mode={inputActiveMode} />
|
||||
<CommunityRecommend mode={inputActiveMode} />
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Flexbox>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { Icon, useAppElement } from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { FileText, FolderIcon } from 'lucide-react';
|
||||
|
|
@ -51,16 +51,20 @@ interface DragState {
|
|||
type: 'file' | 'folder';
|
||||
}
|
||||
|
||||
const DragStateContext = createContext<{
|
||||
currentDrag: DragState | null;
|
||||
const CurrentDragContext = createContext<DragState | null>(null);
|
||||
const SetCurrentDragContext = createContext<((_state: DragState | null) => void) | null>(null);
|
||||
|
||||
setCurrentDrag: (_state: DragState | null) => void;
|
||||
}>({
|
||||
currentDrag: null,
|
||||
setCurrentDrag: () => {},
|
||||
});
|
||||
export const useCurrentDrag = () => use(CurrentDragContext);
|
||||
|
||||
export const useDragState = () => use(DragStateContext);
|
||||
export const useSetCurrentDrag = () => {
|
||||
const setCurrentDrag = use(SetCurrentDragContext);
|
||||
|
||||
if (!setCurrentDrag) {
|
||||
throw new Error('useSetCurrentDrag must be used within DndContextWrapper');
|
||||
}
|
||||
|
||||
return setCurrentDrag;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pragmatic DnD wrapper for resource drag-and-drop
|
||||
|
|
@ -71,10 +75,11 @@ export const DndContextWrapper = memo<PropsWithChildren>(({ children }) => {
|
|||
const { message } = App.useApp();
|
||||
const [currentDrag, setCurrentDrag] = useState<DragState | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const moveResource = useFileStore((s) => s.moveResource);
|
||||
const resourceList = useFileStore((s) => s.resourceList);
|
||||
const selectedFileIds = useResourceManagerStore((s) => s.selectedFileIds);
|
||||
const setSelectedFileIds = useResourceManagerStore((s) => s.setSelectedFileIds);
|
||||
const [moveResource, resourceList] = useFileStore((s) => [s.moveResource, s.resourceList]);
|
||||
const [selectedFileIds, setSelectedFileIds] = useResourceManagerStore((s) => [
|
||||
s.selectedFileIds,
|
||||
s.setSelectedFileIds,
|
||||
]);
|
||||
const libraryId = useResourceManagerStore((s) => s.libraryId);
|
||||
|
||||
// Track mouse position and handle drag events
|
||||
|
|
@ -145,10 +150,6 @@ export const DndContextWrapper = memo<PropsWithChildren>(({ children }) => {
|
|||
|
||||
await Promise.all(pools);
|
||||
|
||||
// Refetch resources to update the view (items should disappear from current folder)
|
||||
const { revalidateResources } = await import('@/store/file/slices/resource/hooks');
|
||||
await revalidateResources();
|
||||
|
||||
// Clear and reload all expanded folders in Tree's module-level cache
|
||||
if (libraryId) {
|
||||
await clearTreeFolderCache(libraryId);
|
||||
|
|
@ -205,6 +206,8 @@ export const DndContextWrapper = memo<PropsWithChildren>(({ children }) => {
|
|||
libraryId,
|
||||
]);
|
||||
|
||||
const appElement = useAppElement();
|
||||
|
||||
// Change cursor to grabbing during drag
|
||||
useEffect(() => {
|
||||
let styleElement: HTMLStyleElement | null = null;
|
||||
|
|
@ -237,93 +240,95 @@ export const DndContextWrapper = memo<PropsWithChildren>(({ children }) => {
|
|||
|
||||
return (
|
||||
<DragActiveContext value={currentDrag !== null}>
|
||||
<DragStateContext value={{ currentDrag, setCurrentDrag }}>
|
||||
{children}
|
||||
{typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
currentDrag ? (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: cssVar.colorBgElevated,
|
||||
border: `1px solid ${cssVar.colorPrimaryBorder}`,
|
||||
borderRadius: cssVar.borderRadiusLG,
|
||||
boxShadow: cssVar.boxShadow,
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
height: 44,
|
||||
left: '-999px',
|
||||
maxWidth: 320,
|
||||
minWidth: 200,
|
||||
padding: '0 12px',
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: '-999px',
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
willChange: 'transform',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<CurrentDragContext value={currentDrag}>
|
||||
<SetCurrentDragContext value={setCurrentDrag}>
|
||||
{children}
|
||||
{typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
currentDrag ? (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
color: cssVar.colorPrimary,
|
||||
background: cssVar.colorBgElevated,
|
||||
border: `1px solid ${cssVar.colorPrimaryBorder}`,
|
||||
borderRadius: cssVar.borderRadiusLG,
|
||||
boxShadow: cssVar.boxShadow,
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
height: 44,
|
||||
left: '-999px',
|
||||
maxWidth: 320,
|
||||
minWidth: 200,
|
||||
padding: '0 12px',
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: '-999px',
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
willChange: 'transform',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
{currentDrag.data.fileType === 'custom/folder' ? (
|
||||
<Icon icon={FolderIcon} size={20} />
|
||||
) : currentDrag.data.fileType === 'custom/document' ? (
|
||||
<Icon icon={FileText} size={20} />
|
||||
) : (
|
||||
<FileIcon
|
||||
fileName={currentDrag.data.name}
|
||||
fileType={currentDrag.data.fileType}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
color: cssVar.colorText,
|
||||
flex: 1,
|
||||
fontSize: cssVar.fontSize,
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{currentDrag.data.name}
|
||||
</span>
|
||||
{selectedFileIds.includes(currentDrag.id) && selectedFileIds.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: cssVar.colorPrimary,
|
||||
borderRadius: cssVar.borderRadiusSM,
|
||||
color: cssVar.colorTextLightSolid,
|
||||
color: cssVar.colorPrimary,
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
height: 22,
|
||||
justifyContent: 'center',
|
||||
minWidth: 22,
|
||||
padding: '0 6px',
|
||||
}}
|
||||
>
|
||||
{selectedFileIds.length}
|
||||
{currentDrag.data.fileType === 'custom/folder' ? (
|
||||
<Icon icon={FolderIcon} size={20} />
|
||||
) : currentDrag.data.fileType === 'custom/document' ? (
|
||||
<Icon icon={FileText} size={20} />
|
||||
) : (
|
||||
<FileIcon
|
||||
fileName={currentDrag.data.name}
|
||||
fileType={currentDrag.data.fileType}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null,
|
||||
document.body,
|
||||
)}
|
||||
</DragStateContext>
|
||||
<span
|
||||
style={{
|
||||
color: cssVar.colorText,
|
||||
flex: 1,
|
||||
fontSize: cssVar.fontSize,
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{currentDrag.data.name}
|
||||
</span>
|
||||
{selectedFileIds.includes(currentDrag.id) && selectedFileIds.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: cssVar.colorPrimary,
|
||||
borderRadius: cssVar.borderRadiusSM,
|
||||
color: cssVar.colorTextLightSolid,
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
height: 22,
|
||||
justifyContent: 'center',
|
||||
minWidth: 22,
|
||||
padding: '0 6px',
|
||||
}}
|
||||
>
|
||||
{selectedFileIds.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null,
|
||||
appElement ?? document.body,
|
||||
)}
|
||||
</SetCurrentDragContext>
|
||||
</CurrentDragContext>
|
||||
</DragActiveContext>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { useResourceManagerFetchFolderBreadcrumb } from '../store';
|
||||
import { useFolderPath } from './useFolderPath';
|
||||
|
||||
export const useCurrentFolderId = () => {
|
||||
const { currentFolderSlug } = useFolderPath();
|
||||
const { data: folderBreadcrumb } = useResourceManagerFetchFolderBreadcrumb(currentFolderSlug);
|
||||
|
||||
return folderBreadcrumb?.at(-1)?.id || null;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue