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 ( + + ); +} 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 - - + + +
    -
    +

    Django Filer Demo

    -
    +
    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: