diff --git a/client/browser/FinderFileSelect.tsx b/client/browser/FinderFileSelect.tsx
new file mode 100644
index 000000000..0209a3973
--- /dev/null
+++ b/client/browser/FinderFileSelect.tsx
@@ -0,0 +1,179 @@
+import React, {createRef, useEffect, useState} from 'react';
+import {File, Folder} from "../finder/Item";
+import ArrowDownIcon from '../icons/arrow-down.svg';
+import ArrowRightIcon from '../icons/arrow-right.svg';
+import EmptyIcon from '../icons/empty.svg';
+import FolderIcon from '../icons/folder.svg';
+import RootIcon from '../icons/root.svg';
+
+
+function InodeList(props) {
+ const {folderId} = props;
+ const [isLoading, setLoading] = useState(false);
+ const [inodes, setInodes] = useState([]);
+ const [searchQuery, setSearchQuery] = useState(() => {
+ const params = new URLSearchParams(window.location.search);
+ return params.get('q');
+ });
+
+ useEffect(() => {
+ fetchInodes();
+ }, [searchQuery]);
+
+ async function fetchInodes() {
+ const params = new URLSearchParams({q: searchQuery});
+ const fetchInodesUrl = `${props.baseurl}${folderId}/fetch${searchQuery ? `?${params.toString()}` : ''}`;
+ setLoading(true);
+ const response = await fetch(fetchInodesUrl);
+ if (response.ok) {
+ const body = await response.json();
+ setInodes(body.inodes.map(inode => {
+ const elementRef = createRef();
+ return {...inode, elementRef};
+ }));
+ } else {
+ console.error(response);
+ }
+ setLoading(false);
+ }
+
+ function selectInode(event: PointerEvent, inode) {
+ if (inode.disabled)
+ return;
+ let modifier, selectedIndex = -1;
+ // simple click
+ if (inode.selected) {
+ modifier = f => ({...f, selected: false});
+ } else {
+ if (!(event.target as HTMLElement)?.classList.contains('inode-name')) {
+ // prevent selecting the inode when clicking on the name field to edit it
+ modifier = f => ({...f, selected: f.id === inode.id});
+ selectedIndex = inodes.findIndex(f => f.id === inode.id); // remember for an upcoming shift-click
+ } else {
+ modifier = f => f;
+ }
+ }
+ const modifiedInodes = inodes.map((f, k) => ({...modifier(f, k), cutted: false, copied: false}));
+ setInodes(modifiedInodes);
+ }
+
+ function deselectInodes() {
+ if (inodes.find(inode => inode.selected || inode.dragged)) {
+ setInodes(inodes.map(inode => ({...inode, selected: false, dragged: false})));
+ }
+ }
+
+ function renderInodes() {
+ if (isLoading)
+ return (
{gettext("Loading...")});
+
+ if (inodes.length === 0) {
+ if (searchQuery)
+ return ({`No match while searching for “${searchQuery}”`});
+ return ({gettext("This folder is empty")});
+ }
+
+ return inodes.map(inode => inode.is_folder
+ ?
+ :
+ );
+ }
+
+ function cssClasses() {
+ const classes = ['inode-list'];
+ if (isLoading)
+ classes.push('loading');
+ return classes.join(' ');
+ }
+
+ return (
+
+ );
+}
+
+
+function FolderEntry(props) {
+ const {folder, toggleOpen} = props;
+
+ if (folder.is_root) {
+ return ();
+ }
+
+ return (<>
+ {
+ (folder.num_subfolders === 0) ? : folder.is_open ? :
+ }
+ {folder.name}
+ >);
+}
+
+
+function FolderStructure(props) {
+ const {baseurl, folder, refreshStructure} = props;
+
+ async function fetchChildren() {
+ const response = await fetch(`${baseurl}fetch/${folder.id}?open`);
+ if (response.ok) {
+ const reply = await response.json();
+ folder.name = reply.name;
+ folder.num_subfolders = reply.num_subfolders;
+ folder.children = reply.children;
+ } else {
+ console.error(response);
+ }
+ }
+
+ async function toggleOpen() {
+ folder.is_open = !folder.is_open;
+ if (folder.is_open) {
+ if (folder.children === null) {
+ await fetchChildren();
+ } else {
+ await fetch(`${baseurl}open/${folder.id}`);
+ }
+ } else {
+ await fetch(`${baseurl}close/${folder.id}`);
+ }
+ refreshStructure();
+ }
+
+ return folder ? (
+
+
+ {folder.is_open && (
+ {folder.children.map(child => (
+
+ ))}
+
)}
+
+ ) : null;
+}
+
+
+export default function FinderFileSelect(props) {
+ const [structure, setStructure] = useState({root_folder: null});
+
+ useEffect(() => {
+ getStructure();
+ }, []);
+
+ async function getStructure() {
+ const response = await fetch(`${props.baseurl}structure/${props.realm}`);
+ if (response.ok) {
+ setStructure(await response.json());
+ } else {
+ console.error(response);
+ }
+ }
+
+ return (
+
+ {
+ setStructure({root_folder: structure.root_folder});
+ }}
+ />
+
+ );
+}
diff --git a/client/browserbuild.config.mjs b/client/browserbuild.config.mjs
new file mode 100644
index 000000000..db9d5fd04
--- /dev/null
+++ b/client/browserbuild.config.mjs
@@ -0,0 +1,21 @@
+import {build} from 'esbuild';
+import svgr from 'esbuild-plugin-svgr';
+import parser from 'yargs-parser';
+const buildOptions = parser(process.argv.slice(2), {
+ boolean: ['debug', 'minify'],
+});
+
+await build({
+ entryPoints: [
+ 'client/finder-browser.ts',
+ ],
+ bundle: true,
+ minify: buildOptions.minify,
+ sourcemap: buildOptions.debug,
+ outfile: 'finder/static/finder/js/browser.js',
+ format: 'esm',
+ jsx: 'automatic',
+ plugins: [svgr()],
+ loader: {'.svg': 'text', '.jsx': 'jsx' },
+ target: ['es2020', 'chrome84', 'firefox84', 'safari14', 'edge84']
+}).catch(() => process.exit(1));
diff --git a/client/finder-browser.scss b/client/finder-browser.scss
new file mode 100644
index 000000000..df05a3491
--- /dev/null
+++ b/client/finder-browser.scss
@@ -0,0 +1,23 @@
+.folder-structure {
+ ul ul {
+ padding-left: 1.5rem;
+ }
+ &, ul {
+ padding-left: 0;
+ list-style: none;
+ }
+
+ li {
+ i {
+ display: inline-block;
+ cursor: pointer;
+ width: 1.5rem;
+ }
+
+ svg {
+ height: 20px;
+ vertical-align: text-bottom;
+ margin-right: 0.5em;
+ }
+ }
+}
diff --git a/client/finder-browser.ts b/client/finder-browser.ts
new file mode 100644
index 000000000..de6deb6c2
--- /dev/null
+++ b/client/finder-browser.ts
@@ -0,0 +1,9 @@
+import r2wc from '@r2wc/react-to-web-component';
+import FinderFileSelect from 'browser/FinderFileSelect';
+
+
+window.addEventListener('DOMContentLoaded', (event) => {
+ window.customElements.define('finder-file-select', r2wc(FinderFileSelect, {
+ props: {baseurl: 'string', realm: 'string'}}
+ ));
+});
diff --git a/client/icons/arrow-down.svg b/client/icons/arrow-down.svg
new file mode 100644
index 000000000..25a9e132a
--- /dev/null
+++ b/client/icons/arrow-down.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/client/icons/arrow-right.svg b/client/icons/arrow-right.svg
new file mode 100644
index 000000000..2dd539d23
--- /dev/null
+++ b/client/icons/arrow-right.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/client/icons/empty.svg b/client/icons/empty.svg
new file mode 100644
index 000000000..a7de8ec94
--- /dev/null
+++ b/client/icons/empty.svg
@@ -0,0 +1,2 @@
+
\ No newline at end of file
diff --git a/client/icons/folder.svg b/client/icons/folder.svg
new file mode 100644
index 000000000..fdf8187ed
--- /dev/null
+++ b/client/icons/folder.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/finder/api/__init__.py b/finder/api/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/finder/api/urls.py b/finder/api/urls.py
new file mode 100644
index 000000000..f604c3fad
--- /dev/null
+++ b/finder/api/urls.py
@@ -0,0 +1,35 @@
+from django.urls import path
+from django.views.i18n import JavaScriptCatalog
+
+from finder.api.views import BrowserView
+
+
+app_name = 'finder-api'
+urlpatterns = [
+ path(
+ 'structure/',
+ BrowserView.as_view(action='structure'),
+ ),
+ path(
+ 'fetch/',
+ BrowserView.as_view(action='fetch'),
+ ),
+ path(
+ 'open/',
+ BrowserView.as_view(action='open'),
+ ),
+ path(
+ 'close/',
+ BrowserView.as_view(action='close'),
+ ),
+ path(
+ 'jsi18n/',
+ JavaScriptCatalog.as_view(packages=['finder']),
+ name="javascript-catalog",
+ ),
+ path(
+ '',
+ BrowserView.as_view(),
+ name="base-url",
+ ),
+]
diff --git a/finder/api/views.py b/finder/api/views.py
new file mode 100644
index 000000000..b11e5346d
--- /dev/null
+++ b/finder/api/views.py
@@ -0,0 +1,103 @@
+from django.contrib.sites.shortcuts import get_current_site
+from django.db.models import Q
+from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotFound
+from django.views import View
+
+from finder.models.folder import FolderModel, RealmModel
+
+
+class BrowserView(View):
+ """
+ The view for web component .
+ """
+ action = None
+
+ def get(self, request, *args, **kwargs):
+ action = getattr(self, self.action, None)
+ if not callable(action):
+ return HttpResponseBadRequest(f"Action {self.action} not allowed.")
+ return action(request, *args, **kwargs)
+
+ def structure(self, request, realm):
+ def get_children(parent):
+ open_folders = request.session['finder.open_folders']
+ result = []
+ for child in FolderModel.objects.filter(parent=parent):
+ child_id = str(child.id)
+ if child_id in open_folders:
+ children = get_children(child)
+ else:
+ children = None
+ result.append({
+ 'id': child_id,
+ 'name': child.name,
+ 'children': children,
+ 'is_open': children is not None,
+ 'num_subfolders': child.subfolders.count(),
+ })
+ return result
+
+ site = get_current_site(request)
+ try:
+ realm = RealmModel.objects.get(site=site, slug=realm)
+ except RealmModel.DoesNotExist:
+ return HttpResponseNotFound(f"Realm {realm} not found for {site.domain}.")
+ root_folder = FolderModel.objects.get_root_folder(realm)
+ request.session.setdefault('finder.open_folders', [])
+ return JsonResponse({
+ 'root_folder': {
+ 'id': str(root_folder.id),
+ 'name': None, # the root folder has no readable name
+ 'is_root': True,
+ 'is_open': FolderModel.objects.filter(parent=root_folder).exists(),
+ 'children': get_children(root_folder),
+ 'num_subfolders': root_folder.subfolders.count(),
+ }
+ })
+
+ def fetch(self, request, folder_id):
+ queryset = FolderModel.objects.filter(Q(id=folder_id) | Q(parent_id=folder_id))
+ try:
+ folder = queryset.get(id=folder_id)
+ except FolderModel.DoesNotExist:
+ return HttpResponseNotFound(f"Folder {folder_id} not found.")
+ else:
+ children = queryset.filter(parent_id=folder_id)
+
+ folder_id = str(folder_id)
+ if 'open' in request.GET:
+ request.session.setdefault('finder.open_folders', [])
+ if folder_id not in request.session['finder.open_folders']:
+ request.session['finder.open_folders'].append(folder_id)
+ request.session.modified = True
+
+ return JsonResponse({
+ 'id': folder.id,
+ 'name': folder.name,
+ 'children': [{
+ 'id': str(child.id),
+ 'name': child.name,
+ 'children': None, # children may be fetched lazily
+ 'num_subfolders': child.subfolders.count(),
+ } for child in children],
+ 'num_subfolders': folder.subfolders.count(),
+ })
+
+ def open(self, request, folder_id):
+ folder_id = str(folder_id)
+ request.session.setdefault('finder.open_folders', [])
+ if folder_id not in request.session['finder.open_folders']:
+ request.session['finder.open_folders'].append(folder_id)
+ request.session.modified = True
+
+ return JsonResponse({})
+
+ def close(self, request, folder_id):
+ folder_id = str(folder_id)
+ try:
+ request.session['finder.open_folders'].remove(folder_id)
+ request.session.modified = True
+ except (KeyError, ValueError):
+ pass
+
+ return JsonResponse({})
diff --git a/finder/models/folder.py b/finder/models/folder.py
index 08718d84a..a2c3b9a00 100644
--- a/finder/models/folder.py
+++ b/finder/models/folder.py
@@ -79,6 +79,10 @@ def casted(self):
def folder(self):
return self
+ @property
+ def subfolders(self):
+ return FolderModel.objects.filter(parent=self)
+
@property
def num_children(self):
num_children = sum(inode_model.objects.filter(parent=self).count() for inode_model in InodeModel.real_models)
diff --git a/package.json b/package.json
index eff6aca82..1c52432cb 100644
--- a/package.json
+++ b/package.json
@@ -4,13 +4,17 @@
"private": true,
"scripts": {
"adminbuild": "node client/adminbuild.config.mjs",
+ "browserbuild": "node client/browserbuild.config.mjs",
"compilescss": "sass --load-path=. client:finder/static/finder/css/",
+ "buildall": "concurrently \"npm run adminbuild -- --debug \" \"npm run browserbuild -- --debug \" \"npm run compilescss\""
},
"devDependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^6.0.1",
+ "@r2wc/react-to-web-component": "^2.0.3",
"@types/react-dom": "^18.3.0",
"@wavesurfer/react": "^1.0.7",
+ "bootstrap": "^5.3.3",
"concurrently": "^8.2.0",
"esbuild": "^0.19.12",
"esbuild-plugin-svgr": "^2.1.0",
diff --git a/testapp/templates/testapp.html b/testapp/templates/testapp.html
index a90be71f7..a2e643e44 100644
--- a/testapp/templates/testapp.html
+++ b/testapp/templates/testapp.html
@@ -8,16 +8,17 @@
Django-Filer
-
-
+
+
+
diff --git a/testapp/urls.py b/testapp/urls.py
index afb99eeda..78c8dc96d 100644
--- a/testapp/urls.py
+++ b/testapp/urls.py
@@ -18,9 +18,9 @@
from django.contrib import admin
from django.http import HttpResponse
from django.template.loader import get_template
-from django.urls import path
+from django.urls import include, path
-from testapp.admin_site import admin_site
+from finder.api import urls as finder_urls
def render_landing(request):
@@ -31,7 +31,7 @@ def render_landing(request):
urlpatterns = [
path('admin/', admin.site.urls),
- path('testapp/admin/', admin_site.urls),
+ path('finder-api/', include(finder_urls)),
path('testapp/', render_landing),
]
if settings.DEBUG: