Compare commits
	
		
			13 Commits
		
	
	
		
			ffafbc87d0
			...
			v0.5.0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | c40c245ce6 | ||
|   | 1fdd00b833 | ||
|   | 520a9dff47 | ||
|   | c5c65d136a | ||
|   | 61f9026e23 | ||
|   | 3e50149d4d | ||
|   | 7077b21159 | ||
|   | 938c5ca657 | ||
|   | e0aef07783 | ||
|   | 36826a83c1 | ||
|   | 6880f82c19 | ||
|   | 5dd1bd9bdc | ||
|   | 41e8c78ecd | 
							
								
								
									
										115
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										115
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,27 +1,116 @@ | ||||
| # Web File Storage | ||||
| # Cista Web Storage | ||||
|  | ||||
| Run directly from repository with Hatch (or use pip install as usual): | ||||
| <img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.jpg" align=right width=250> | ||||
|  | ||||
| ```sh | ||||
| hatch run cista -l :3000 /path/to/files | ||||
| Cista takes its name from the ancient cistae, metal containers used by Greeks and Egyptians to safeguard valuable items. This modern application provides a browser interface for secure and accessible file storage, echoing the trust and reliability of its historical namesake. | ||||
|  | ||||
| This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**. | ||||
|  | ||||
| The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter. | ||||
|  | ||||
| All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible. | ||||
|  | ||||
| Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something... | ||||
|  | ||||
|  | ||||
| ## Getting Started | ||||
| ### Installation | ||||
|  | ||||
| To install the cista application, use: | ||||
|  | ||||
| ```fish | ||||
| pip install cista | ||||
| ``` | ||||
|  | ||||
| Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script). | ||||
| Note: Some Linux distributions might need `--break-system-packages` to install Python packages, which are safely installed in the user's home folder. As an alternative to avoid installation, run it with command `pipx run cista` | ||||
|  | ||||
| Create your user account: | ||||
| ### Running the Server | ||||
|  | ||||
| ```sh | ||||
| hatch run cista --user admin --privileged | ||||
| Create an account: (or run a public server without authentication) | ||||
| ```fish | ||||
| cista --user yourname --privileged | ||||
| ``` | ||||
|  | ||||
| ## Build frontend | ||||
| Serve your files at http://localhost:8000: | ||||
| ```fish | ||||
| cista -l :8000 /path/to/files | ||||
| ``` | ||||
|  | ||||
| Frontend needs to be built before using and after any frontend changes: | ||||
| The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments. | ||||
|  | ||||
| ```sh | ||||
| ### Internet Access | ||||
|  | ||||
| To use your own TLS certificates, place them in the config folder and run: | ||||
|  | ||||
| ```fish | ||||
| cista -l cista.example.com | ||||
| ``` | ||||
|  | ||||
| Most admins instead find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address. Caddy configuration **/etc/caddy/Caddyfile** is dead simple: | ||||
|  | ||||
| ```Caddyfile | ||||
| cista.example.com { | ||||
|     reverse_proxy :8000 | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## Development setup | ||||
|  | ||||
| For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified. | ||||
|  | ||||
| ```fish | ||||
| cd frontend | ||||
| npm install | ||||
| npm run build | ||||
| npm run dev | ||||
| ``` | ||||
|  | ||||
| This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`. | ||||
| Concurrently, start the backend on another terminal: | ||||
|  | ||||
| ```fish | ||||
| hatch shell | ||||
| pip install -e '.[dev]' | ||||
| cista --dev -l :8000 /path/to/files | ||||
| ``` | ||||
|  | ||||
| We use `hatch shell` for installing on a virtual environment, to not disturb the rest of the system with our hacking. | ||||
|  | ||||
| Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. Running `hatch build` builds the frontend and creates a NodeJS-independent Python package. | ||||
|  | ||||
| ## System Deployment | ||||
|  | ||||
| This setup allows easy addition of storages, each with its own domain, configuration, and files. | ||||
|  | ||||
| Assuming a restricted user account **storage** for serving files and that cista is installed system-wide or on this account (check with `sudo -u storage -s`). Alternatively, use `pipx run cista` or `hatch run cista` as the ExecStart command. | ||||
|  | ||||
| Create **/etc/systemd/system/cista@.service**: | ||||
|  | ||||
| ```ini | ||||
| [Unit] | ||||
| Description=Cista storage %i | ||||
|  | ||||
| [Service] | ||||
| User=storage | ||||
| ExecStart=cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/ | ||||
| Restart=always | ||||
|  | ||||
| [Install] | ||||
| WantedBy=multi-user.target | ||||
| ``` | ||||
|  | ||||
| This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience. | ||||
|  | ||||
| ```fish | ||||
| systemctl daemon-reload | ||||
| systemctl enable --now cista@foo.example.com | ||||
| systemctl enable --now cista@bar.example.com | ||||
| ``` | ||||
|  | ||||
| Public exposure is easiest using the Caddy web server, but Nginx or others also work. Run the server with -l domain.example.com if you have TLS certificates in the config folder. | ||||
|  | ||||
| **/etc/caddy/Caddyfile**: | ||||
|  | ||||
| ```Caddyfile | ||||
| foo.example.com, bar.example.com { | ||||
|     reverse_proxy unix//srv/cista/{host}/socket | ||||
| } | ||||
| ``` | ||||
|   | ||||
| @@ -62,12 +62,15 @@ def _main(): | ||||
|     _confdir(args) | ||||
|     exists = config.conffile.exists() | ||||
|     import_droppy = args["--import-droppy"] | ||||
|     necessary_opts = exists or import_droppy or path and listen | ||||
|     necessary_opts = exists or import_droppy or path | ||||
|     if not necessary_opts: | ||||
|         # Maybe run without arguments | ||||
|         print(doc) | ||||
|         print( | ||||
|             "No config file found! Get started with:\n  cista -l :8000 /path/to/files, or\n  cista -l example.com --import-droppy  # Uses Droppy files\n", | ||||
|             "No config file found! Get started with one of:\n" | ||||
|             "  cista --user yourname --privileged\n" | ||||
|             "  cista --import-droppy\n" | ||||
|             "  cista -l :8000 /path/to/files\n" | ||||
|         ) | ||||
|         return 1 | ||||
|     settings = {} | ||||
| @@ -79,8 +82,15 @@ def _main(): | ||||
|         settings = droppy.readconf() | ||||
|     if path: | ||||
|         settings["path"] = path | ||||
|     elif not exists: | ||||
|         settings["path"] = Path.home() / "Downloads" | ||||
|     if listen: | ||||
|         settings["listen"] = listen | ||||
|     elif not exists: | ||||
|         settings["listen"] = ":8000" | ||||
|     if not exists and not import_droppy: | ||||
|         # We have no users, so make it public | ||||
|         settings["public"] = True | ||||
|     operation = config.update_config(settings) | ||||
|     print(f"Config {operation}: {config.conffile}") | ||||
|     # Prepare to serve | ||||
| @@ -112,11 +122,23 @@ def _confdir(args): | ||||
|  | ||||
| def _user(args): | ||||
|     _confdir(args) | ||||
|     config.load_config() | ||||
|     if config.conffile.exists(): | ||||
|         config.load_config() | ||||
|         operation = False | ||||
|     else: | ||||
|         # Defaults for new config when user is created | ||||
|         operation = config.update_config( | ||||
|             { | ||||
|                 "listen": ":8000", | ||||
|                 "path": Path.home() / "Downloads", | ||||
|                 "public": False, | ||||
|             } | ||||
|         ) | ||||
|         print(f"Config {operation}: {config.conffile}\n") | ||||
|  | ||||
|     name = args["--user"] | ||||
|     if not name or not name.isidentifier(): | ||||
|         raise ValueError("Invalid username") | ||||
|     config.load_config() | ||||
|     u = config.config.users.get(name) | ||||
|     info = f"User {name}" if u else f"New user {name}" | ||||
|     changes = {} | ||||
| @@ -128,12 +150,17 @@ def _user(args): | ||||
|         info += " (admin)" if oldadmin else "" | ||||
|     if args["--password"] or not u: | ||||
|         changes["password"] = pw = pwgen.generate() | ||||
|         info += f"\n  Password: {pw}" | ||||
|     res = config.update_user(args["--user"], changes) | ||||
|         info += f"\n  Password: {pw}\n" | ||||
|     res = config.update_user(name, changes) | ||||
|     print(info) | ||||
|     if res == "read": | ||||
|         print("  No changes") | ||||
|  | ||||
|     if operation == "created": | ||||
|         print( | ||||
|             "Now you can run the server:\n  cista    # defaults set: -l :8000 ~/Downloads\n" | ||||
|         ) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     sys.exit(main()) | ||||
|   | ||||
							
								
								
									
										19
									
								
								cista/app.py
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								cista/app.py
									
									
									
									
									
								
							| @@ -11,7 +11,7 @@ import brotli | ||||
| import sanic.helpers | ||||
| from blake3 import blake3 | ||||
| from sanic import Blueprint, Sanic, empty, raw | ||||
| from sanic.exceptions import Forbidden, NotFound, ServerError | ||||
| from sanic.exceptions import Forbidden, NotFound | ||||
| from sanic.log import logging | ||||
| from stream_zip import ZIP_AUTO, stream_zip | ||||
|  | ||||
| @@ -120,10 +120,19 @@ def _load_wwwroot(www): | ||||
|                 br = False | ||||
|             wwwnew[name] = data, br, headers | ||||
|     if not wwwnew: | ||||
|         raise ServerError( | ||||
|             "Web frontend missing. Did you forget npm run build?", | ||||
|             extra={"wwwroot": str(base)}, | ||||
|             quiet=True, | ||||
|         msg = f"Web frontend missing from {base}\n  Did you forget: hatch build\n" | ||||
|         if not www: | ||||
|             logging.warning(msg) | ||||
|         if not app.debug: | ||||
|             msg = "Web frontend missing. Cista installation is broken.\n" | ||||
|         wwwnew[""] = ( | ||||
|             msg.encode(), | ||||
|             False, | ||||
|             { | ||||
|                 "etag": "error", | ||||
|                 "content-type": "text/plain", | ||||
|                 "cache-control": "no-store", | ||||
|             }, | ||||
|         ) | ||||
|     return wwwnew | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import secrets | ||||
| import sys | ||||
| from functools import wraps | ||||
| from hashlib import sha256 | ||||
| from pathlib import Path, PurePath | ||||
| @@ -90,6 +91,8 @@ def config_update(modify): | ||||
|             return "read" | ||||
|         f.write(new) | ||||
|         f.close() | ||||
|         if sys.platform == "win32": | ||||
|             conffile.unlink()  # Windows doesn't support atomic replace | ||||
|         tmpname.rename(conffile)  # Atomic replace | ||||
|     except: | ||||
|         f.close() | ||||
| @@ -135,7 +138,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config: | ||||
|     # Encode into dict, update values with new, convert to Config | ||||
|     try: | ||||
|         u = conf.users[name].__copy__() | ||||
|     except KeyError: | ||||
|     except (KeyError, AttributeError): | ||||
|         u = User() | ||||
|     if "password" in changes: | ||||
|         from . import auth | ||||
| @@ -144,7 +147,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config: | ||||
|         del changes["password"] | ||||
|     udict = msgspec.to_builtins(u, enc_hook=enc_hook) | ||||
|     udict.update(changes) | ||||
|     settings = msgspec.to_builtins(conf, enc_hook=enc_hook) | ||||
|     settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}} | ||||
|     settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook) | ||||
|     return msgspec.convert(settings, Config, dec_hook=dec_hook) | ||||
|  | ||||
|   | ||||
| @@ -149,15 +149,3 @@ class Space(msgspec.Struct): | ||||
|     free: int | ||||
|     usage: int | ||||
|     storage: int | ||||
|  | ||||
|  | ||||
| def make_dir_data(root): | ||||
|     if len(root) == 3: | ||||
|         return FileEntry(*root) | ||||
|     id_, size, mtime, listing = root | ||||
|     converted = {} | ||||
|     for name, data in listing.items(): | ||||
|         converted[name] = make_dir_data(data) | ||||
|     sz = sum(x.size for x in converted.values()) | ||||
|     mt = max(x.mtime for x in converted.values()) | ||||
|     return DirEntry(id_, sz, max(mt, mtime), converted) | ||||
|   | ||||
| @@ -110,26 +110,6 @@ class State: | ||||
|         with self.lock: | ||||
|             del self._listing[self._slice(relpath)] | ||||
|  | ||||
|     def _index(self, rel: PurePosixPath): | ||||
|         idx = 0 | ||||
|         ret = [] | ||||
|  | ||||
|     def _dir(self, idx: int): | ||||
|         level = self._listing[idx].level + 1 | ||||
|         end = len(self._listing) | ||||
|         idx += 1 | ||||
|         ret = [] | ||||
|         while idx < end and (r := self._listing[idx]).level >= level: | ||||
|             if r.level == level: | ||||
|                 ret.append(idx) | ||||
|         return ret, idx | ||||
|  | ||||
|     def update(self, rel: PurePosixPath, value: FileEntry): | ||||
|         begin = 0 | ||||
|         parents = [] | ||||
|         while self._listing[begin].level < len(rel.parts): | ||||
|             parents.append(begin) | ||||
|  | ||||
|  | ||||
| state = State() | ||||
| rootpath: Path = None  # type: ignore | ||||
| @@ -149,7 +129,7 @@ def watcher_thread(loop): | ||||
|     global rootpath | ||||
|     import inotify.adapters | ||||
|  | ||||
|     while True: | ||||
|     while not quit: | ||||
|         rootpath = config.config.path | ||||
|         i = inotify.adapters.InotifyTree(rootpath.as_posix()) | ||||
|         # Initialize the tree from filesystem | ||||
| @@ -160,8 +140,8 @@ def watcher_thread(loop): | ||||
|                 state.root = new | ||||
|                 broadcast(format_update(old, new), loop) | ||||
|  | ||||
|         # The watching is not entirely reliable, so do a full refresh every minute | ||||
|         refreshdl = time.monotonic() + 60.0 | ||||
|         # The watching is not entirely reliable, so do a full refresh every 30 seconds | ||||
|         refreshdl = time.monotonic() + 30.0 | ||||
|  | ||||
|         for event in i.event_gen(): | ||||
|             if quit: | ||||
| @@ -238,11 +218,15 @@ def _walk(rel: PurePosixPath, isfile: int, st: stat_result) -> list[FileEntry]: | ||||
|     try: | ||||
|         li = [] | ||||
|         for f in path.iterdir(): | ||||
|             if quit: | ||||
|                 raise SystemExit("quit") | ||||
|             if f.name.startswith("."): | ||||
|                 continue  # No dotfiles | ||||
|             s = f.stat() | ||||
|             li.append((int(not stat.S_ISDIR(s.st_mode)), f.name, s)) | ||||
|         for [isfile, name, s] in humansorted(li): | ||||
|             if quit: | ||||
|                 raise SystemExit("quit") | ||||
|             subtree = _walk(rel / name, isfile, s) | ||||
|             child = subtree[0] | ||||
|             entry.mtime = max(entry.mtime, child.mtime) | ||||
| @@ -337,7 +321,7 @@ async def abroadcast(msg): | ||||
|  | ||||
| async def start(app, loop): | ||||
|     config.load_config() | ||||
|     use_inotify = False and sys.platform == "linux" | ||||
|     use_inotify = sys.platform == "linux" | ||||
|     app.ctx.watcher = threading.Thread( | ||||
|         target=watcher_thread if use_inotify else watcher_thread_poll, | ||||
|         args=[loop], | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								docs/cista.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/cista.jpg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 39 KiB | 
| @@ -4,7 +4,7 @@ | ||||
|     <HeaderMain ref="headerMain" :path="path.pathList" :query="path.query"> | ||||
|       <HeaderSelected :path="path.pathList" /> | ||||
|     </HeaderMain> | ||||
|     <BreadCrumb :path="path.pathList" tabindex="-1"/> | ||||
|     <BreadCrumb :path="path.pathList" primary /> | ||||
|   </header> | ||||
|   <main> | ||||
|     <RouterView :path="path.pathList" :query="path.query" /> | ||||
| @@ -51,6 +51,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => { | ||||
|   const fileExplorer = store.fileExplorer as any | ||||
|   if (!fileExplorer) return | ||||
|   const c = fileExplorer.isCursor() | ||||
|   const input = (event.target as HTMLElement).tagName === 'INPUT' | ||||
|   const keyup = event.type === 'keyup' | ||||
|   if (event.repeat) { | ||||
|     if ( | ||||
| @@ -70,13 +71,21 @@ const globalShortcutHandler = (event: KeyboardEvent) => { | ||||
|   else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) { | ||||
|     headerMain.value!.toggleSearchInput() | ||||
|   } | ||||
|   // Search also on / (UNIX style) | ||||
|   else if (keyup && !input && event.key === '/') { | ||||
|     headerMain.value!.toggleSearchInput() | ||||
|   } | ||||
|   // Globally close search on Escape | ||||
|   else if (keyup && event.key === 'Escape') { | ||||
|     headerMain.value!.closeSearch(event) | ||||
|   } | ||||
|   // Select all (toggle); keydown to prevent builtin | ||||
|   else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) { | ||||
|     fileExplorer.toggleSelectAll() | ||||
|   } | ||||
|   // Keys 1-3 to sort columns | ||||
|   else if ( | ||||
|     c && | ||||
|     !input && | ||||
|     keyup && | ||||
|     (event.key === '1' || event.key === '2' || event.key === '3') | ||||
|   ) { | ||||
|   | ||||
| @@ -4,7 +4,9 @@ | ||||
|     aria-label="Breadcrumb" | ||||
|     @keyup.left.stop="move(-1)" | ||||
|     @keyup.right.stop="move(1)" | ||||
|     @focus="move(0)" | ||||
|     @keyup.enter="move(0)" | ||||
|     @focus=focusCurrent | ||||
|     tabindex=0 | ||||
|   > | ||||
|     <a href="#/" | ||||
|       :ref="el => setLinkRef(0, el)" | ||||
| @@ -26,7 +28,7 @@ | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import home from '@/assets/svg/home.svg' | ||||
| import { onBeforeUpdate, ref, watchEffect } from 'vue' | ||||
| import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue' | ||||
| import { useRouter } from 'vue-router' | ||||
|  | ||||
| const router = useRouter() | ||||
| @@ -37,22 +39,33 @@ onBeforeUpdate(() => { links.length = 1 })  // 1 to keep home | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   path: Array<string> | ||||
|   primary?: boolean | ||||
| }>() | ||||
|  | ||||
| const longest = ref<Array<string>>([]) | ||||
|  | ||||
| const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined | ||||
|  | ||||
| const focusCurrent = () => { | ||||
|   nextTick(() => { | ||||
|     const index = props.path.length | ||||
|     if (index < links.length) links[index].focus() | ||||
|   }) | ||||
| } | ||||
|  | ||||
| const navigate = (index: number) => { | ||||
|   const link = links[index] | ||||
|   if (!link) throw Error(`No link at index ${index} (path: ${props.path})`) | ||||
|   const url = `/${longest.value.slice(0, index).join('/')}/` | ||||
|   const here = `/${longest.value.join('/')}/` | ||||
|   const current = decodeURIComponent(location.hash.slice(1).split('//')[0]) | ||||
|   const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/' | ||||
|   const long = longest.value.length ? `/${longest.value.join('/')}/` : '/' | ||||
|   const browser = decodeURIComponent(location.hash.slice(1).split('//')[0]) | ||||
|   const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') | ||||
|   if (here.startsWith(current)) router.replace(u) | ||||
|   // Clicking on current link clears the rest of the path and adds new history | ||||
|   if (isCurrent(index)) { longest.value.splice(index); router.push(u) } | ||||
|   // Moving along breadcrumbs doesn't create new history | ||||
|   else if (long.startsWith(browser)) router.replace(u) | ||||
|   // Nornal navigation from elsewhere (e.g. search result breadcrumbs) | ||||
|   else router.push(u) | ||||
|   link.focus() | ||||
| } | ||||
|  | ||||
| const move = (dir: number) => { | ||||
| @@ -64,13 +77,16 @@ const move = (dir: number) => { | ||||
| watchEffect(() => { | ||||
|   const longcut = longest.value.slice(0, props.path.length) | ||||
|   const same = longcut.every((value, index) => value === props.path[index]) | ||||
|   // Navigated out of previous path, reset longest to current | ||||
|   if (!same) longest.value = props.path | ||||
|   else if (props.path.length > longcut.length) { | ||||
|     longest.value = longcut.concat(props.path.slice(longcut.length)) | ||||
|   } | ||||
| }) | ||||
| watchEffect(() => { | ||||
|   if (links.length) navigate(props.path.length) | ||||
|   // If needed, focus primary navigation to new location | ||||
|   if (props.primary) nextTick(() => { | ||||
|     const act = document.activeElement as HTMLElement | ||||
|     if (!act || [...links, document.body].includes(act)) focusCurrent() | ||||
|   }) | ||||
| }) | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -5,9 +5,9 @@ | ||||
|         <th class="selection"> | ||||
|           <input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate"> | ||||
|         </th> | ||||
|         <th class="sortcolumn" :class="{ sortactive: sort === 'name' }" @click="toggleSort('name')">Name</th> | ||||
|         <th class="sortcolumn modified right" :class="{ sortactive: sort === 'modified' }" @click="toggleSort('modified')">Modified</th> | ||||
|         <th class="sortcolumn size right" :class="{ sortactive: sort === 'size' }" @click="toggleSort('size')">Size</th> | ||||
|         <th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th> | ||||
|         <th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th> | ||||
|         <th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th> | ||||
|         <th class="menu"></th> | ||||
|       </tr> | ||||
|     </thead> | ||||
| @@ -21,7 +21,7 @@ | ||||
|         <FileSize :doc=editing /> | ||||
|         <td class="menu"></td> | ||||
|       </tr> | ||||
|       <template v-for="(doc, index) in sortedDocuments" :key="doc.key"> | ||||
|       <template v-for="(doc, index) in documents" :key="doc.key"> | ||||
|         <tr class="folder-change" v-if="showFolderBreadcrumb(index)"> | ||||
|           <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th> | ||||
|         </tr> | ||||
| @@ -58,13 +58,13 @@ | ||||
|                 @keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }" | ||||
|                 >{{ doc.name }}</a | ||||
|               > | ||||
|               <button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button> | ||||
|               <button tabindex=-1 v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button> | ||||
|             </template> | ||||
|           </td> | ||||
|           <FileModified :doc=doc :key=nowkey /> | ||||
|           <FileSize :doc=doc /> | ||||
|           <td class="menu"> | ||||
|             <button tabindex="-1" @click.stop="contextMenu($event, doc)">⋮</button> | ||||
|             <button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button> | ||||
|           </td> | ||||
|         </tr> | ||||
|       </template> | ||||
| @@ -84,8 +84,10 @@ import { useMainStore } from '@/stores/main' | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import FileRenameInput from './FileRenameInput.vue' | ||||
| import { connect, controlUrl } from '@/repositories/WS' | ||||
| import { collator, formatSize } from '@/utils' | ||||
| import { formatSize } from '@/utils' | ||||
| import { useRouter } from 'vue-router' | ||||
| import ContextMenu from '@imengyu/vue3-context-menu' | ||||
| import type { SortOrder } from '@/utils/docsort' | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   path: Array<string> | ||||
| @@ -120,12 +122,6 @@ const rename = (doc: Doc, newName: string) => { | ||||
|   } | ||||
|   doc.name = newName // We should get an update from watch but this is quicker | ||||
| } | ||||
| const sortedDocuments = computed(() => sorted(props.documents)) | ||||
| const showFolderBreadcrumb = (i: number) => { | ||||
|   const docs = sortedDocuments.value | ||||
|   const docloc = docs[i].loc | ||||
|   return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc | ||||
| } | ||||
| defineExpose({ | ||||
|   newFolder() { | ||||
|     const now = Math.floor(Date.now() / 1000) | ||||
| @@ -143,8 +139,8 @@ defineExpose({ | ||||
|     allSelected.value = !allSelected.value | ||||
|   }, | ||||
|   toggleSortColumn(column: number) { | ||||
|     const columns = ['', 'name', 'modified', 'size', ''] | ||||
|     toggleSort(columns[column]) | ||||
|     const order = ['', 'name', 'modified', 'size', ''][column] | ||||
|     if (order) store.toggleSort(order as SortOrder) | ||||
|   }, | ||||
|   isCursor() { | ||||
|     return cursor.value !== null && editing.value === null | ||||
| @@ -164,25 +160,25 @@ defineExpose({ | ||||
|   }, | ||||
|   cursorMove(d: number, select = false) { | ||||
|     // Move cursor up or down (keyboard navigation) | ||||
|     const documents = sortedDocuments.value | ||||
|     if (documents.length === 0) { | ||||
|     const docs = props.documents | ||||
|     if (docs.length === 0) { | ||||
|       cursor.value = null | ||||
|       return | ||||
|     } | ||||
|     const N = documents.length | ||||
|     const N = docs.length | ||||
|     const mod = (a: number, b: number) => ((a % b) + b) % b | ||||
|     const increment = (i: number, d: number) => mod(i + d, N + 1) | ||||
|     const index = | ||||
|       cursor.value !== null ? documents.indexOf(cursor.value) : documents.length | ||||
|       cursor.value !== null ? docs.indexOf(cursor.value) : docs.length | ||||
|     const moveto = increment(index, d) | ||||
|     cursor.value = documents[moveto] ?? null | ||||
|     cursor.value = docs[moveto] ?? null | ||||
|     const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null | ||||
|     if (select) { | ||||
|       // Go forwards, possibly wrapping over the end; the last entry is not toggled | ||||
|       let [begin, end] = d > 0 ? [index, moveto] : [moveto, index] | ||||
|       for (let p = begin; p !== end; p = increment(p, 1)) { | ||||
|         if (p === N) continue | ||||
|         const key = documents[p].key | ||||
|         const key = docs[p].key | ||||
|         if (store.selected.has(key)) store.selected.delete(key) | ||||
|         else store.selected.add(key) | ||||
|       } | ||||
| @@ -253,22 +249,10 @@ const mkdir = (doc: Doc, name: string) => { | ||||
|   doc.name = name | ||||
|   doc.key = crypto.randomUUID() | ||||
| } | ||||
|  | ||||
| // Column sort | ||||
| const toggleSort = (name: string) => { | ||||
|   sort.value = sort.value === name ? '' : name | ||||
| } | ||||
| const sort = ref<string>('') | ||||
| const sortCompare = { | ||||
|   name: (a: Doc, b: Doc) => collator.compare(a.name, b.name), | ||||
|   modified: (a: Doc, b: Doc) => b.mtime - a.mtime, | ||||
|   size: (a: Doc, b: Doc) => b.size - a.size | ||||
| } | ||||
| const sorted = (documents: Doc[]) => { | ||||
|   const cmp = sortCompare[sort.value as keyof typeof sortCompare] | ||||
|   const sorted = [...documents] | ||||
|   if (cmp) sorted.sort(cmp) | ||||
|   return sorted | ||||
| const showFolderBreadcrumb = (i: number) => { | ||||
|   const docs = props.documents | ||||
|   const docloc = docs[i].loc | ||||
|   return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc | ||||
| } | ||||
| const selectionIndeterminate = computed({ | ||||
|   get: () => { | ||||
| @@ -302,9 +286,13 @@ const allSelected = computed({ | ||||
|  | ||||
| const loc = computed(() => props.path.join('/')) | ||||
|  | ||||
| const contextMenu = (ev: Event, doc: Doc) => { | ||||
| const contextMenu = (ev: MouseEvent, doc: Doc) => { | ||||
|   cursor.value = doc | ||||
|   console.log('Context menu', ev, doc) | ||||
|   ContextMenu.showContextMenu({ | ||||
|     x: ev.x, y: ev.y, items: [ | ||||
|       { label: 'Rename', onClick: () => { editing.value = doc } }, | ||||
|     ], | ||||
|   }) | ||||
| } | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -18,11 +18,9 @@ | ||||
|           ref="search" | ||||
|           type="search" | ||||
|           :value="query" | ||||
|           @blur="ev => { if (!query) closeSearch(ev) }" | ||||
|           @input="updateSearch" | ||||
|           placeholder="Search words" | ||||
|           class="margin-input" | ||||
|           @keyup.escape="closeSearch" | ||||
|         /> | ||||
|       </template> | ||||
|       <SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" /> | ||||
|   | ||||
							
								
								
									
										129
									
								
								frontend/src/stores/main.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								frontend/src/stores/main.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,129 @@ | ||||
| import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document' | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import { defineStore } from 'pinia' | ||||
| import { collator } from '@/utils' | ||||
| import { logoutUser } from '@/repositories/User' | ||||
| import { watchConnect } from '@/repositories/WS' | ||||
| import { shallowRef } from 'vue' | ||||
| import { sorted, type SortOrder } from '@/utils/docsort' | ||||
|  | ||||
| type User = { | ||||
|   username: string | ||||
|   privileged: boolean | ||||
|   isOpenLoginModal: boolean | ||||
|   isLoggedIn: boolean | ||||
| } | ||||
|  | ||||
| export const useMainStore = defineStore({ | ||||
|   id: 'main', | ||||
|   state: () => ({ | ||||
|     document: shallowRef<Doc[]>([]), | ||||
|     selected: new Set<FUID>(), | ||||
|     query: '' as string, | ||||
|     fileExplorer: null as any, | ||||
|     error: '' as string, | ||||
|     connected: false, | ||||
|     server: {} as Record<string, any>, | ||||
|     prefs: { | ||||
|       sortListing: '' as SortOrder, | ||||
|       sortFiltered: '' as SortOrder, | ||||
|     }, | ||||
|     user: { | ||||
|       username: '', | ||||
|       privileged: false, | ||||
|       isLoggedIn: false, | ||||
|       isOpenLoginModal: false | ||||
|     } as User | ||||
|   }), | ||||
|   persist: { | ||||
|     paths: ['prefs'], | ||||
|   }, | ||||
|   actions: { | ||||
|     updateRoot(root: FileEntry[]) { | ||||
|       const docs = [] | ||||
|       let loc = [] as string[] | ||||
|       for (const [level, name, key, mtime, size, isfile] of root) { | ||||
|         loc = loc.slice(0, level - 1) | ||||
|         docs.push(new Doc({ | ||||
|           name, | ||||
|           loc: level ? loc.join('/') : '/', | ||||
|           key, | ||||
|           size, | ||||
|           mtime, | ||||
|           dir: !isfile, | ||||
|         })) | ||||
|         loc.push(name) | ||||
|       } | ||||
|       this.document = docs | ||||
|     }, | ||||
|     login(username: string, privileged: boolean) { | ||||
|       this.user.username = username | ||||
|       this.user.privileged = privileged | ||||
|       this.user.isLoggedIn = true | ||||
|       this.user.isOpenLoginModal = false | ||||
|       if (!this.connected) watchConnect() | ||||
|     }, | ||||
|     loginDialog() { | ||||
|       this.user.isOpenLoginModal = true | ||||
|     }, | ||||
|     async logout() { | ||||
|       console.log("Logout") | ||||
|       await logoutUser() | ||||
|       this.$reset() | ||||
|       localStorage.clear() | ||||
|       history.go() // Reload page | ||||
|     }, | ||||
|     toggleSort(name: SortOrder) { | ||||
|       if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name | ||||
|       else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name | ||||
|     }, | ||||
|   }, | ||||
|   getters: { | ||||
|     sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing }, | ||||
|     isUserLogged(): boolean { return this.user.isLoggedIn }, | ||||
|     recentDocuments(): Doc[] { return sorted(this.document, 'modified') }, | ||||
|     selectedFiles(): SelectedItems { | ||||
|       const selected = this.selected | ||||
|       const found = new Set<FUID>() | ||||
|       const ret: SelectedItems = { | ||||
|         missing: new Set(), | ||||
|         docs: {}, | ||||
|         keys: [], | ||||
|         recursive: [], | ||||
|       } | ||||
|       for (const doc of this.document) { | ||||
|         if (selected.has(doc.key)) { | ||||
|           found.add(doc.key) | ||||
|           ret.keys.push(doc.key) | ||||
|           ret.docs[doc.key] = doc | ||||
|         } | ||||
|       } | ||||
|       // What did we not select? | ||||
|       for (const key of selected) if (!found.has(key)) ret.missing.add(key) | ||||
|       // Build a flat list including contents recursively | ||||
|       const relnames = new Set<string>() | ||||
|       function add(rel: string, full: string, doc: Doc) { | ||||
|         if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`) | ||||
|         relnames.add(rel) | ||||
|         ret.recursive.push([rel, full, doc]) | ||||
|       } | ||||
|       for (const key of ret.keys) { | ||||
|         const base = ret.docs[key] | ||||
|         const basepath = base.loc ? `${base.loc}/${base.name}` : base.name | ||||
|         const nremove = base.loc.length | ||||
|         add(base.name, basepath, base) | ||||
|         for (const doc of this.document) { | ||||
|           if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') { | ||||
|             const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name | ||||
|             const rel = full.slice(nremove) | ||||
|             add(rel, full, doc) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       // Sort by rel (name stored as on download) | ||||
|       ret.recursive.sort((a, b) => collator.compare(a[0], b[0])) | ||||
|  | ||||
|       return ret | ||||
|     } | ||||
|   } | ||||
| }) | ||||
							
								
								
									
										15
									
								
								frontend/src/utils/docsort.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								frontend/src/utils/docsort.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import { collator } from '@/utils' | ||||
|  | ||||
| export const ordering = { | ||||
|   name: (a: Doc, b: Doc) => collator.compare(a.name, b.name), | ||||
|   modified: (a: Doc, b: Doc) => b.mtime - a.mtime, | ||||
|   size: (a: Doc, b: Doc) => b.size - a.size | ||||
| } | ||||
| export type SortOrder = keyof typeof ordering | '' | ||||
| export const sorted = (documents: Doc[], order: SortOrder) => { | ||||
|   if (!order) return documents | ||||
|   const sorted = [...documents] | ||||
|   sorted.sort(ordering[order]) | ||||
|   return sorted | ||||
| } | ||||
| @@ -13,6 +13,7 @@ import { watchEffect, ref, computed } from 'vue' | ||||
| import { useMainStore } from '@/stores/main' | ||||
| import Router from '@/router/index' | ||||
| import { needleFormat, localeIncludes, collator } from '@/utils'; | ||||
| import { sorted } from '@/utils/docsort'; | ||||
|  | ||||
| const store = useMainStore() | ||||
| const fileExplorer = ref() | ||||
| @@ -24,7 +25,10 @@ const documents = computed(() => { | ||||
|   const loc = props.path.join('/') | ||||
|   const query = props.query | ||||
|   // List the current location | ||||
|   if (!query) return store.document.filter(doc => doc.loc === loc) | ||||
|   if (!query) return sorted( | ||||
|     store.document.filter(doc => doc.loc === loc), | ||||
|     store.prefs.sortListing, | ||||
|   ) | ||||
|   // Find up to 100 newest documents that match the search | ||||
|   const needle = needleFormat(query) | ||||
|   let limit = 100 | ||||
| @@ -35,8 +39,11 @@ const documents = computed(() => { | ||||
|       if (--limit === 0) break | ||||
|     } | ||||
|   } | ||||
|   // Organize by folder, by relevance | ||||
|   const locsub = loc + '/' | ||||
|   // Custom sort override in effect? | ||||
|   const order = store.prefs.sortFiltered | ||||
|   if (order) return sorted(docs, order) | ||||
|   // Sort by relevance - current folder, then subfolders, then others | ||||
|   docs.sort((a, b) => ( | ||||
|     // @ts-ignore | ||||
|     (b.loc === loc) - (a.loc === loc) || | ||||
| @@ -54,5 +61,6 @@ const documents = computed(() => { | ||||
|  | ||||
| watchEffect(() => { | ||||
|   store.fileExplorer = fileExplorer.value | ||||
|   store.query = props.query | ||||
| }) | ||||
| </script> | ||||
|   | ||||
| @@ -29,7 +29,7 @@ dependencies = [ | ||||
| ] | ||||
|  | ||||
| [project.urls] | ||||
| Homepage = "" | ||||
| Homepage = "https://git.zi.fi/Vasanko/cista-storage" | ||||
|  | ||||
| [project.scripts] | ||||
| cista = "cista.__main__:main" | ||||
| @@ -40,20 +40,18 @@ dev = [ | ||||
|     "ruff", | ||||
| ] | ||||
|  | ||||
| [tool.hatchling] | ||||
| # Build frontend | ||||
| pre_build = "npm run build --prefix cista-front" | ||||
|  | ||||
| [tool.hatch.version] | ||||
| source = "vcs" | ||||
|  | ||||
| [tool.hatch.build] | ||||
| artifacts = ["cista/wwwroot"] | ||||
| hooks.custom.path = "scripts/build-frontend.py" | ||||
| hooks.vcs.version-file = "cista/_version.py" | ||||
| hooks.vcs.template = """ | ||||
| # This file is automatically generated by hatch build. | ||||
| __version__ = {version!r} | ||||
| """ | ||||
|  | ||||
| only-packages = true | ||||
| targets.sdist.include = [ | ||||
|     "/cista", | ||||
| ] | ||||
|   | ||||
							
								
								
									
										12
									
								
								scripts/build-frontend.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								scripts/build-frontend.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| # noqa: INP001 | ||||
| import subprocess | ||||
|  | ||||
| from hatchling.builders.hooks.plugin.interface import BuildHookInterface | ||||
|  | ||||
|  | ||||
| class CustomBuildHook(BuildHookInterface): | ||||
|     def initialize(self, version, build_data): | ||||
|         super().initialize(version, build_data) | ||||
|         print("Building Cista frontend...") | ||||
|         subprocess.run("npm install --prefix frontend".split(" "), check=True)  # noqa: S603 | ||||
|         subprocess.run("npm run build --prefix frontend".split(" "), check=True)  # noqa: S603 | ||||
		Reference in New Issue
	
	Block a user