diff --git a/drive/api/files.py b/drive/api/files.py
index 514e2313e..1e541af7f 100644
--- a/drive/api/files.py
+++ b/drive/api/files.py
@@ -150,44 +150,47 @@ def upload_file(
return drive_file
+IMAGE_THUMBNAILS = ["Image", "Video", "PDF", "Presentation"]
+
+
+def _get_default_thumbnail(file_type: str) -> BytesIO:
+ file_path = frappe.get_app_path("drive", "public", "images", "icons", f"{file_type.lower()}.svg")
+ try:
+ with open(file_path, "rb") as f:
+ return BytesIO(f.read())
+ except FileNotFoundError as e:
+ return None
+
+
@frappe.whitelist(allow_guest=True)
-def get_thumbnail(entity_name):
- drive_file = frappe.get_value(
+def get_thumbnail(entity_name, image=True):
+ drive_file = frappe.get_cached_doc(
"Drive File",
entity_name,
- [
- "is_group",
- "path",
- "title",
- "mime_type",
- "file_size",
- "owner",
- "team",
- "document",
- "name",
- ],
- as_dict=1,
- )
- if not drive_file or drive_file.is_group or drive_file.is_link:
+ ).as_dict()
+ if not drive_file or not user_has_permission(drive_file, "read"):
return
- if user_has_permission(drive_file, "read") is False:
- return
-
thumbnail_data = None
+ default = False
+
if frappe.cache().exists(entity_name):
try:
- thumbnail_data = frappe.cache().get_value(entity_name)
+ cache = frappe.cache().get_value(entity_name)
+ thumbnail_data = cache["thumbnail"]
+ default = cache["default"]
except:
frappe.cache().delete_value(entity_name)
+
if not thumbnail_data:
+ file_type = get_file_type(dict(drive_file))
manager = FileManager()
try:
- if drive_file.mime_type.startswith("text"):
+ if not image and drive_file.mime_type.startswith("text"):
with manager.get_file(drive_file) as f:
thumbnail_data = f.read()[:1000].decode("utf-8").replace("\n", "
")
- elif drive_file.mime_type == "frappe_doc":
+ elif not image and drive_file.mime_type == "frappe_doc":
html = frappe.get_value("Drive Document", drive_file.document, "raw_content")
- thumbnail_data = html[:1000] if html else ""
+ thumbnail_data = html[:1000]
elif drive_file.mime_type == "frappe/slides":
# Use this until the thumbnail method is whitelisted
thumbnails = frappe.call(
@@ -197,22 +200,32 @@ def get_thumbnail(entity_name):
frappe.local.response["type"] = "redirect"
frappe.local.response["location"] = thumbnails[0]
return
- else:
+ elif file_type in IMAGE_THUMBNAILS:
thumbnail = manager.get_thumbnail(drive_file.team, entity_name)
thumbnail_data = BytesIO(thumbnail.read())
thumbnail.close()
except:
- return ""
-
- if thumbnail_data:
- frappe.cache().set_value(entity_name, thumbnail_data, expires_in_sec=60 * 60)
+ pass
+ finally:
+ if not thumbnail_data:
+ thumbnail_data = _get_default_thumbnail(file_type)
+ default = True
+ if thumbnail_data:
+ frappe.cache().set_value(
+ entity_name, {"thumbnail": thumbnail_data, "default": default}, expires_in_sec=60 * 60
+ )
if isinstance(thumbnail_data, BytesIO):
response = Response(
wrap_file(frappe.request.environ, thumbnail_data),
direct_passthrough=True,
)
- response.headers.set("Content-Type", "image/jpeg")
+ if default:
+ response.headers.set("Content-Type", "image/svg+xml")
+ response.headers.set("Cache-Control", "public, max-age=3600")
+ else:
+ response.headers.set("Content-Type", "image/jpeg")
+ response.headers.set("X-Thumbnail-Default", "1" if default else "0")
response.headers.set("Content-Disposition", "inline", filename=entity_name)
return response
else:
diff --git a/drive/api/list.py b/drive/api/list.py
index 27fb4726d..d29daf3e2 100644
--- a/drive/api/list.py
+++ b/drive/api/list.py
@@ -27,10 +27,11 @@
def files(
team,
entity_name=None,
+ parent=None,
order_by="modified 1",
is_active=1,
- limit=20,
- cursor=None,
+ limit=1000,
+ start=0,
favourites_only=0,
recents_only=0,
shared=None,
@@ -44,9 +45,13 @@ def files(
field, ascending = order_by.replace("modified", "_modified").split(" ")
is_active = int(is_active)
only_parent = int(only_parent)
+ limit = int(limit)
+ start = int(start)
folders = int(folders)
favourites_only = int(favourites_only)
ascending = int(ascending)
+ if not entity_name:
+ entity_name = parent
all_teams = False
if team == "all":
@@ -99,10 +104,6 @@ def files(
fn.Coalesce(DrivePermission.read, 1).as_("read") == 1
)
- # Cursor pagination
- if cursor:
- query = query.where((Binary(DriveFile[field]) > cursor if ascending else field < cursor)).limit(limit)
-
# Cleaner way?
if only_parent and (not recents_only and not favourites_only and not shared):
query = query.where(DriveFile.parent_entity == entity_name)
@@ -156,8 +157,15 @@ def files(
if folders:
query = query.where(DriveFile.is_group == 1)
+ query = query.limit(limit + 1).offset(start)
res = query.run(as_dict=True)
+ # Check if there's a next page
+ has_next_page = len(res) > limit
+
+ # Remove the extra record if it exists
+ if has_next_page:
+ res = res[:limit]
child_count_query = (
frappe.qb.from_(DriveFile)
.where((DriveFile.team == team) & (DriveFile.is_active == 1))
@@ -208,6 +216,11 @@ def files(
else:
r["share_count"] = default
r |= get_user_access(r["name"])
+
+ # Return in the format useList expects
+ frappe.response["data"] = res
+ frappe.response["has_next_page"] = has_next_page
+
return res
@@ -217,3 +230,8 @@ def get_transfers():
"Drive Transfer", filters={"owner": frappe.session.user}, fields=["title", "file_size", "creation", "name"]
)
return transfers
+
+
+@frappe.whitelist(allow_guest=True)
+def search(query, parent=None, limit=None):
+ return files(parent=parent, limit=limit, search=query)
diff --git a/drive/api/notifications.py b/drive/api/notifications.py
index 3b9081243..bee184a52 100644
--- a/drive/api/notifications.py
+++ b/drive/api/notifications.py
@@ -8,14 +8,19 @@ def get_link(entity):
@frappe.whitelist()
-def get_notifications(only_unread):
+def get_notifications(only_unread, limit=20, offset=0):
"""
- Get notifications for current user
-
- :param only_unread: only get notifications where read is False
+ Get notifications for current user (paged)
+ :param limit: page size
+ :param offset: offset for pagination
"""
+
+ limit = int(limit)
+ offset = int(offset)
+
User = frappe.qb.DocType("User")
Notification = frappe.qb.DocType("Drive Notification")
+
fields = [
Notification.name,
Notification.to_user,
@@ -30,19 +35,28 @@ def get_notifications(only_unread):
User.user_image,
User.full_name,
]
+
query = (
frappe.qb.from_(Notification)
.left_join(User)
.on(Notification.from_user == User.name)
.select(*fields)
+ .where(Notification.to_user == frappe.session.user)
.orderby(Notification.creation, order=Order.desc)
+ .limit(limit + 1)
+ .offset(offset)
)
if only_unread:
query = query.where(Notification.read == 0)
- query = query.where(Notification.to_user == frappe.session.user)
- result = query.run(as_dict=True)
- return result
+
+ rows = query.run(as_dict=True)
+
+ has_next_page = len(rows) > limit
+ res = rows[:limit]
+
+ frappe.response["data"] = res
+ frappe.response["has_next_page"] = has_next_page
@frappe.whitelist()
diff --git a/frappe-ui b/frappe-ui
index 3dd5b3375..a302a61dc 160000
--- a/frappe-ui
+++ b/frappe-ui
@@ -1 +1 @@
-Subproject commit 3dd5b33758a472b131b62624a1c67e752a65915d
+Subproject commit a302a61dc32bfe1e868412f67dad6f7ca144254d
diff --git a/frontend/index.html b/frontend/index.html
index 00d34e21c..23794a969 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -18,12 +18,12 @@
property="og:description"
content="{{ description }}"
/>
- {% if og_image %}
+
- {% if og_image %}
+
=3.5.0'
vue-router: ^4.1.6
@@ -8219,7 +8219,7 @@ snapshots:
fraction.js@4.3.7: {}
- frappe-ui@0.1.212(@babel/parser@7.28.4)(@nuxt/kit@3.19.1(magicast@0.3.5))(@vue/compiler-sfc@3.5.21)(tailwindcss@3.4.17)(vue-router@4.5.1(vue@3.5.21(typescript@5.9.2)))(vue@3.5.21(typescript@5.9.2)):
+ frappe-ui@0.1.220(@babel/parser@7.28.4)(@nuxt/kit@3.19.1(magicast@0.3.5))(@vue/compiler-sfc@3.5.21)(tailwindcss@3.4.17)(vue-router@4.5.1(vue@3.5.21(typescript@5.9.2)))(vue@3.5.21(typescript@5.9.2)):
dependencies:
'@floating-ui/vue': 1.1.8(vue@3.5.21(typescript@5.9.2))
'@headlessui/vue': 1.7.23(vue@3.5.21(typescript@5.9.2))
diff --git a/frontend/src/components/CustomListRowItem.vue b/frontend/src/components/CustomListRowItem.vue
index fc5575987..ec0c4d383 100644
--- a/frontend/src/components/CustomListRowItem.vue
+++ b/frontend/src/components/CustomListRowItem.vue
@@ -10,18 +10,11 @@
#prefix
>
-
@@ -71,15 +64,4 @@ const props = defineProps({
item: String,
contextMenu: Function,
})
-
-let src, imgLoaded, thumbnailLink, backupLink, _
-
-if (props.column.prefix && props.column.key === "title") {
- ;[thumbnailLink, backupLink, _] = props.column.prefix({
- row: props.row,
- })
-
- src = ref(thumbnailLink || backupLink)
- imgLoaded = ref(false)
-}
diff --git a/frontend/src/components/GenericPage.vue b/frontend/src/components/GenericPage.vue
index 4ecde6ef9..f0b954c6f 100644
--- a/frontend/src/components/GenericPage.vue
+++ b/frontend/src/components/GenericPage.vue
@@ -25,7 +25,6 @@
:selections="selectedEntitities"
:get-entities="getEntities || { data: [] }"
/>
-
d },
showSort: { type: Boolean, default: true },
verify: { type: Object, default: null },
@@ -147,13 +160,15 @@ const sortId = computed(
() =>
props.getEntities.params?.entity_name || props.getEntities.params?.personal
)
-const sortOrder = ref(
- store.state.sortOrder[sortId.value] || {
+const sortOrder = defineModel("orderBy")
+if (!sortOrder.value) {
+ sortOrder.value = store.state.sortOrder[sortId.value] || {
label: "Modified",
field: "modified",
ascending: false,
}
-)
+}
+
const search = ref("")
const filters = ref([])
@@ -167,16 +182,40 @@ watch(
(order) => {
rows.value = sortEntities([...rows.value], order)
props.getEntities.setData(rows.value)
- if (sortId.value) {
- store.commit("setSortOrder", [sortId.value, order])
+ if (sortId.value || props.id) {
+ store.commit("setSortOrder", [sortId.value || props.id, order])
}
},
{ deep: true }
)
+const searchServer = createResource({
+ url: "drive.api.list.search",
+ makeParams: (params) => ({ ...params, parent: props.id, limit: 100 }),
+})
+
+const searchFunc = () => {
+ if (props.getEntities.data > 250 && !props.getEntities.hasNextPage) {
+ const query = new RegExp(search.value, "i")
+ rows.value = props.getEntities.data.filter((k) => query.test(k.title))
+ } else {
+ searchServer.fetch(
+ {
+ query: search.value,
+ },
+ {
+ onSuccess: (data) => {
+ rows.value = prettyData(data)
+ },
+ }
+ )
+ }
+}
+const searchDebounced = debounce(searchFunc, 300)
watch(search, (val) => {
- const search = new RegExp(val, "i")
- rows.value = props.getEntities.data.filter((k) => search.test(k.title))
+ if (!val) {
+ rows.value = props.getEntities.data
+ } else searchDebounced()
})
watch(
@@ -202,7 +241,7 @@ watch(
if (!val) return
rows.value = sortEntities([...val], sortOrder.value)
store.commit("setCurrentFolder", {
- entities: rows.value.filter?.((k) => k.title[0] !== "."),
+ entities: rows.value,
})
},
{ immediate: true, deep: true }
@@ -224,13 +263,13 @@ watchEffect(() => {
if (verifyAccess.value?.write) useEventListener("paste", pasteObj)
})
-const refreshData = () => {
+const refreshData = debounce(() => {
const params = { team: team.value === "home" ? "" : team.value || "" }
if (sortOrder.value)
params.order_by =
sortOrder.value.field + (sortOrder.value.ascending ? " 1" : " 0")
- props.getEntities.fetch({ ...props.getEntities.params, ...params })
-}
+ props.getEntities.fetch()
+}, 100)
watch(
[verifyAccess, team],
@@ -464,6 +503,21 @@ socket.on("client-rename", ({ entity_name, title }) => {
const file = props.getEntities.data.find((k) => k.name === entity_name)
file.title = title
})
+
+const viewEl = useTemplateRef("viewEl")
+const scrollEl = computed(() => viewEl.value?.scrollEl)
+useInfiniteScroll(
+ scrollEl,
+ () => {
+ if (!props.getEntities.loading) props.getEntities.next()
+ },
+ {
+ distance: 100,
+ canLoadMore: () => {
+ return !search.value && props.getEntities.hasNextPage
+ },
+ }
+)