Compare commits
3 Commits
v0.5.0
...
19a5c4ad8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19a5c4ad8a | ||
|
|
3d3b078e60 | ||
|
|
d4e91ea9a6 |
115
README.md
115
README.md
@@ -1,116 +1,27 @@
|
||||
# Cista Web Storage
|
||||
# Web File Storage
|
||||
|
||||
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.jpg" align=right width=250>
|
||||
Run directly from repository with Hatch (or use pip install as usual):
|
||||
|
||||
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
|
||||
```sh
|
||||
hatch run cista -l :3000 /path/to/files
|
||||
```
|
||||
|
||||
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`
|
||||
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).
|
||||
|
||||
### Running the Server
|
||||
Create your user account:
|
||||
|
||||
Create an account: (or run a public server without authentication)
|
||||
```fish
|
||||
cista --user yourname --privileged
|
||||
```sh
|
||||
hatch run cista --user admin --privileged
|
||||
```
|
||||
|
||||
Serve your files at http://localhost:8000:
|
||||
```fish
|
||||
cista -l :8000 /path/to/files
|
||||
```
|
||||
## Build frontend
|
||||
|
||||
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
||||
Frontend needs to be built before using and after any frontend changes:
|
||||
|
||||
### 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
|
||||
```sh
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
npm run 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
|
||||
}
|
||||
```
|
||||
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`.
|
||||
|
||||
@@ -62,15 +62,12 @@ def _main():
|
||||
_confdir(args)
|
||||
exists = config.conffile.exists()
|
||||
import_droppy = args["--import-droppy"]
|
||||
necessary_opts = exists or import_droppy or path
|
||||
necessary_opts = exists or import_droppy or path and listen
|
||||
if not necessary_opts:
|
||||
# Maybe run without arguments
|
||||
print(doc)
|
||||
print(
|
||||
"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"
|
||||
"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",
|
||||
)
|
||||
return 1
|
||||
settings = {}
|
||||
@@ -82,15 +79,8 @@ 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
|
||||
@@ -122,23 +112,11 @@ def _confdir(args):
|
||||
|
||||
def _user(args):
|
||||
_confdir(args)
|
||||
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")
|
||||
|
||||
config.load_config()
|
||||
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 = {}
|
||||
@@ -150,17 +128,12 @@ def _user(args):
|
||||
info += " (admin)" if oldadmin else ""
|
||||
if args["--password"] or not u:
|
||||
changes["password"] = pw = pwgen.generate()
|
||||
info += f"\n Password: {pw}\n"
|
||||
res = config.update_user(name, changes)
|
||||
info += f"\n Password: {pw}"
|
||||
res = config.update_user(args["--user"], 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
|
||||
from sanic.exceptions import Forbidden, NotFound, ServerError
|
||||
from sanic.log import logging
|
||||
from stream_zip import ZIP_AUTO, stream_zip
|
||||
|
||||
@@ -120,19 +120,10 @@ def _load_wwwroot(www):
|
||||
br = False
|
||||
wwwnew[name] = data, br, headers
|
||||
if not wwwnew:
|
||||
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",
|
||||
},
|
||||
raise ServerError(
|
||||
"Web frontend missing. Did you forget npm run build?",
|
||||
extra={"wwwroot": str(base)},
|
||||
quiet=True,
|
||||
)
|
||||
return wwwnew
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sys
|
||||
from functools import wraps
|
||||
from hashlib import sha256
|
||||
from pathlib import Path, PurePath
|
||||
@@ -91,8 +90,6 @@ 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()
|
||||
@@ -138,7 +135,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, AttributeError):
|
||||
except KeyError:
|
||||
u = User()
|
||||
if "password" in changes:
|
||||
from . import auth
|
||||
@@ -147,7 +144,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) if conf else {"users": {}}
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
|
||||
@@ -149,3 +149,15 @@ 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,6 +110,26 @@ 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
|
||||
@@ -129,7 +149,7 @@ def watcher_thread(loop):
|
||||
global rootpath
|
||||
import inotify.adapters
|
||||
|
||||
while not quit:
|
||||
while True:
|
||||
rootpath = config.config.path
|
||||
i = inotify.adapters.InotifyTree(rootpath.as_posix())
|
||||
# Initialize the tree from filesystem
|
||||
@@ -140,8 +160,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 30 seconds
|
||||
refreshdl = time.monotonic() + 30.0
|
||||
# The watching is not entirely reliable, so do a full refresh every minute
|
||||
refreshdl = time.monotonic() + 60.0
|
||||
|
||||
for event in i.event_gen():
|
||||
if quit:
|
||||
@@ -218,15 +238,11 @@ 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)
|
||||
@@ -321,7 +337,7 @@ async def abroadcast(msg):
|
||||
|
||||
async def start(app, loop):
|
||||
config.load_config()
|
||||
use_inotify = sys.platform == "linux"
|
||||
use_inotify = False and sys.platform == "linux"
|
||||
app.ctx.watcher = threading.Thread(
|
||||
target=watcher_thread if use_inotify else watcher_thread_poll,
|
||||
args=[loop],
|
||||
|
||||
BIN
docs/cista.jpg
BIN
docs/cista.jpg
Binary file not shown.
|
Before 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" primary />
|
||||
<BreadCrumb :path="path.pathList" tabindex="-1"/>
|
||||
</header>
|
||||
<main>
|
||||
<RouterView :path="path.pathList" :query="path.query" />
|
||||
@@ -17,7 +17,7 @@ import type { ComputedRef } from 'vue'
|
||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import Router from '@/router/index'
|
||||
@@ -27,7 +27,7 @@ interface Path {
|
||||
pathList: string[]
|
||||
query: string
|
||||
}
|
||||
const store = useMainStore()
|
||||
const documentStore = useDocumentStore()
|
||||
const path: ComputedRef<Path> = computed(() => {
|
||||
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
||||
const pathList = p[0].split('/').filter(value => value !== '')
|
||||
@@ -39,7 +39,7 @@ const path: ComputedRef<Path> = computed(() => {
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage'
|
||||
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || documentStore.server.name || 'Cista Storage'
|
||||
})
|
||||
onMounted(loadSession)
|
||||
onMounted(watchConnect)
|
||||
@@ -48,10 +48,9 @@ const headerMain = ref<typeof HeaderMain | null>(null)
|
||||
let vert = 0
|
||||
let timer: any = null
|
||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
const fileExplorer = store.fileExplorer as any
|
||||
const fileExplorer = documentStore.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 (
|
||||
@@ -71,21 +70,13 @@ 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 (
|
||||
!input &&
|
||||
c &&
|
||||
keyup &&
|
||||
(event.key === '1' || event.key === '2' || event.key === '3')
|
||||
) {
|
||||
@@ -133,4 +124,3 @@ onUnmounted(() => {
|
||||
})
|
||||
export type { Path }
|
||||
</script>
|
||||
@/stores/main
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
aria-label="Breadcrumb"
|
||||
@keyup.left.stop="move(-1)"
|
||||
@keyup.right.stop="move(1)"
|
||||
@keyup.enter="move(0)"
|
||||
@focus=focusCurrent
|
||||
tabindex=0
|
||||
@focus="move(0)"
|
||||
>
|
||||
<a href="#/"
|
||||
:ref="el => setLinkRef(0, el)"
|
||||
@@ -28,7 +26,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import home from '@/assets/svg/home.svg'
|
||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||
import { onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -39,33 +37,20 @@ 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 = 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')
|
||||
// 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)
|
||||
const url = `/${longest.value.slice(0, index).join('/')}/`
|
||||
const here = `/${longest.value.join('/')}/`
|
||||
link.focus()
|
||||
if (here.startsWith(location.hash.slice(1))) router.replace(url)
|
||||
else router.push(url)
|
||||
}
|
||||
|
||||
const move = (dir: number) => {
|
||||
@@ -77,16 +62,13 @@ 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))
|
||||
}
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (links.length) navigate(props.path.length)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<th class="selection">
|
||||
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
||||
</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="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="menu"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -21,7 +21,7 @@
|
||||
<FileSize :doc=editing />
|
||||
<td class="menu"></td>
|
||||
</tr>
|
||||
<template v-for="(doc, index) in documents" :key="doc.key">
|
||||
<template v-for="(doc, index) in sortedDocuments" :key="doc.key">
|
||||
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
||||
</tr>
|
||||
@@ -36,11 +36,11 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
tabindex="-1"
|
||||
:checked="store.selected.has(doc.key)"
|
||||
:checked="documentStore.selected.has(doc.key)"
|
||||
@change="
|
||||
($event.target as HTMLInputElement).checked
|
||||
? store.selected.add(doc.key)
|
||||
: store.selected.delete(doc.key)
|
||||
? documentStore.selected.add(doc.key)
|
||||
: documentStore.selected.delete(doc.key)
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
@@ -50,7 +50,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<a
|
||||
:href="doc.url"
|
||||
:href="url_for(doc)"
|
||||
tabindex="-1"
|
||||
@contextmenu.prevent
|
||||
@focus.stop="cursor = doc"
|
||||
@@ -58,13 +58,13 @@
|
||||
@keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }"
|
||||
>{{ doc.name }}</a
|
||||
>
|
||||
<button tabindex=-1 v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||
<button 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>
|
||||
@@ -80,21 +80,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
import { connect, controlUrl } from '@/repositories/WS'
|
||||
import { formatSize } from '@/utils'
|
||||
import { collator, 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>
|
||||
documents: Doc[]
|
||||
}>()
|
||||
const store = useMainStore()
|
||||
const documentStore = useDocumentStore()
|
||||
const router = useRouter()
|
||||
const url_for = (doc: Doc) => {
|
||||
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
return doc.dir ? `#/${p}/` : `/files/${p}`
|
||||
}
|
||||
const cursor = shallowRef<Doc | null>(null)
|
||||
// File rename
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
@@ -122,6 +124,12 @@ 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)
|
||||
@@ -139,8 +147,8 @@ defineExpose({
|
||||
allSelected.value = !allSelected.value
|
||||
},
|
||||
toggleSortColumn(column: number) {
|
||||
const order = ['', 'name', 'modified', 'size', ''][column]
|
||||
if (order) store.toggleSort(order as SortOrder)
|
||||
const columns = ['', 'name', 'modified', 'size', '']
|
||||
toggleSort(columns[column])
|
||||
},
|
||||
isCursor() {
|
||||
return cursor.value !== null && editing.value === null
|
||||
@@ -151,36 +159,36 @@ defineExpose({
|
||||
cursorSelect() {
|
||||
const doc = cursor.value
|
||||
if (!doc) return
|
||||
if (store.selected.has(doc.key)) {
|
||||
store.selected.delete(doc.key)
|
||||
if (documentStore.selected.has(doc.key)) {
|
||||
documentStore.selected.delete(doc.key)
|
||||
} else {
|
||||
store.selected.add(doc.key)
|
||||
documentStore.selected.add(doc.key)
|
||||
}
|
||||
this.cursorMove(1)
|
||||
},
|
||||
cursorMove(d: number, select = false) {
|
||||
// Move cursor up or down (keyboard navigation)
|
||||
const docs = props.documents
|
||||
if (docs.length === 0) {
|
||||
const documents = sortedDocuments.value
|
||||
if (documents.length === 0) {
|
||||
cursor.value = null
|
||||
return
|
||||
}
|
||||
const N = docs.length
|
||||
const N = documents.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 ? docs.indexOf(cursor.value) : docs.length
|
||||
cursor.value !== null ? documents.indexOf(cursor.value) : documents.length
|
||||
const moveto = increment(index, d)
|
||||
cursor.value = docs[moveto] ?? null
|
||||
cursor.value = documents[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 = docs[p].key
|
||||
if (store.selected.has(key)) store.selected.delete(key)
|
||||
else store.selected.add(key)
|
||||
const key = documents[p].key
|
||||
if (documentStore.selected.has(key)) documentStore.selected.delete(key)
|
||||
else documentStore.selected.add(key)
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
@@ -241,7 +249,7 @@ const mkdir = (doc: Doc, name: string) => {
|
||||
editing.value = null
|
||||
} else {
|
||||
console.log('mkdir', msg)
|
||||
router.push(doc.urlrouter)
|
||||
router.push(doc.loc ? `/${doc.loc}/${name}/` : `/${name}/`)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -249,16 +257,28 @@ const mkdir = (doc: Doc, name: string) => {
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
}
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
const docs = props.documents
|
||||
const docloc = docs[i].loc
|
||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
||||
|
||||
// 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 selectionIndeterminate = computed({
|
||||
get: () => {
|
||||
return (
|
||||
props.documents.length > 0 &&
|
||||
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
|
||||
props.documents.some((doc: Doc) => documentStore.selected.has(doc.key)) &&
|
||||
!allSelected.value
|
||||
)
|
||||
},
|
||||
@@ -269,16 +289,16 @@ const allSelected = computed({
|
||||
get: () => {
|
||||
return (
|
||||
props.documents.length > 0 &&
|
||||
props.documents.every((doc: Doc) => store.selected.has(doc.key))
|
||||
props.documents.every((doc: Doc) => documentStore.selected.has(doc.key))
|
||||
)
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
console.log('Setting allSelected', value)
|
||||
for (const doc of props.documents) {
|
||||
if (value) {
|
||||
store.selected.add(doc.key)
|
||||
documentStore.selected.add(doc.key)
|
||||
} else {
|
||||
store.selected.delete(doc.key)
|
||||
documentStore.selected.delete(doc.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,13 +306,9 @@ const allSelected = computed({
|
||||
|
||||
const loc = computed(() => props.path.join('/'))
|
||||
|
||||
const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
const contextMenu = (ev: Event, doc: Doc) => {
|
||||
cursor.value = doc
|
||||
ContextMenu.showContextMenu({
|
||||
x: ev.x, y: ev.y, items: [
|
||||
{ label: 'Rename', onClick: () => { editing.value = doc } },
|
||||
],
|
||||
})
|
||||
console.log('Context menu', ev, doc)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -440,4 +456,3 @@ tbody .selection input {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<nav class="headermain">
|
||||
<div class="buttons">
|
||||
<template v-if="store.error">
|
||||
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
|
||||
<template v-if="documentStore.error">
|
||||
<div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div>
|
||||
<div class="smallgap"></div>
|
||||
</template>
|
||||
<UploadButton :path="props.path" />
|
||||
<SvgButton
|
||||
name="create-folder"
|
||||
data-tooltip="New folder"
|
||||
@click="() => store.fileExplorer!.newFolder()"
|
||||
@click="() => documentStore.fileExplorer!.newFolder()"
|
||||
/>
|
||||
<slot></slot>
|
||||
<div class="spacer smallgap"></div>
|
||||
@@ -18,9 +18,11 @@
|
||||
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" />
|
||||
@@ -30,19 +32,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { ref, nextTick, watchEffect } from 'vue'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import router from '@/router';
|
||||
|
||||
const store = useMainStore()
|
||||
const documentStore = useDocumentStore()
|
||||
const showSearchInput = ref<boolean>(false)
|
||||
const search = ref<HTMLInputElement | null>()
|
||||
const searchButton = ref<HTMLButtonElement | null>()
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
query: string
|
||||
}>()
|
||||
|
||||
const closeSearch = (ev: Event) => {
|
||||
if (!showSearchInput.value) return // Already closing
|
||||
@@ -56,9 +54,9 @@ const updateSearch = (ev: Event) => {
|
||||
let p = props.path.join('/')
|
||||
p = p ? `/${p}` : ''
|
||||
const url = q ? `${p}//${q}` : (p || '/')
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
if (!props.query && q) router.push(u)
|
||||
else router.replace(u)
|
||||
console.log("Update search", url)
|
||||
if (!props.query && q) router.push(url)
|
||||
else router.replace(url)
|
||||
}
|
||||
const toggleSearchInput = (ev: Event) => {
|
||||
showSearchInput.value = !showSearchInput.value
|
||||
@@ -74,10 +72,10 @@ watchEffect(() => {
|
||||
const settingsMenu = (e: Event) => {
|
||||
// show the context menu
|
||||
const items = []
|
||||
if (store.user.isLoggedIn) {
|
||||
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
|
||||
if (documentStore.user.isLoggedIn) {
|
||||
items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() })
|
||||
} else {
|
||||
items.push({ label: 'Login', onClick: () => store.loginDialog() })
|
||||
items.push({ label: 'Login', onClick: () => documentStore.loginDialog() })
|
||||
}
|
||||
ContextMenu.showContextMenu({
|
||||
// @ts-ignore
|
||||
@@ -85,6 +83,11 @@ const settingsMenu = (e: Event) => {
|
||||
items,
|
||||
})
|
||||
}
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
query: string
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
toggleSearchInput,
|
||||
closeSearch,
|
||||
@@ -113,4 +116,3 @@ input[type='search'] {
|
||||
max-width: 30vw;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
<template>
|
||||
<template v-if="store.selected.size">
|
||||
<template v-if="documentStore.selected.size">
|
||||
<div class="smallgap"></div>
|
||||
<p class="select-text">{{ store.selected.size }} selected ➤</p>
|
||||
<p class="select-text">{{ documentStore.selected.size }} selected ➤</p>
|
||||
<SvgButton name="download" data-tooltip="Download" @click="download" />
|
||||
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
|
||||
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
|
||||
<SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" />
|
||||
<button class="action-button unselect" data-tooltip="Unselect all" @click="store.selected.clear()">❌</button>
|
||||
<button class="action-button unselect" data-tooltip="Unselect all" @click="documentStore.selected.clear()">❌</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {connect, controlUrl} from '@/repositories/WS'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { computed } from 'vue'
|
||||
import type { SelectedItems } from '@/repositories/Document'
|
||||
|
||||
const store = useMainStore()
|
||||
const documentStore = useDocumentStore()
|
||||
const props = defineProps({
|
||||
path: Array<string>
|
||||
})
|
||||
|
||||
const dst = computed(() => props.path!.join('/'))
|
||||
const op = (op: string, dst?: string) => {
|
||||
const sel = store.selectedFiles
|
||||
const sel = documentStore.selectedFiles
|
||||
const msg = {
|
||||
op,
|
||||
sel: sel.keys.map(key => {
|
||||
@@ -38,12 +38,12 @@ const op = (op: string, dst?: string) => {
|
||||
const res = JSON.parse(ev.data)
|
||||
if ('error' in res) {
|
||||
console.error('Control socket error', msg, res.error)
|
||||
store.error = res.error.message
|
||||
documentStore.error = res.error.message
|
||||
return
|
||||
} else if (res.status === 'ack') {
|
||||
console.log('Control ack OK', res)
|
||||
control.close()
|
||||
store.selected.clear()
|
||||
documentStore.selected.clear()
|
||||
return
|
||||
} else console.log('Unknown control response', msg, res)
|
||||
}
|
||||
@@ -108,17 +108,17 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
const sel = store.selectedFiles
|
||||
const sel = documentStore.selectedFiles
|
||||
console.log('Download', sel)
|
||||
if (sel.keys.length === 0) {
|
||||
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
||||
store.selected.clear()
|
||||
documentStore.selected.clear()
|
||||
return
|
||||
}
|
||||
// Plain old a href download if only one file (ignoring any folders)
|
||||
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
||||
if (files.length === 1) {
|
||||
store.selected.clear()
|
||||
documentStore.selected.clear()
|
||||
return linkdl(`/files/${files[0][1]}`)
|
||||
}
|
||||
// Use FileSystem API if multiple files and the browser supports it
|
||||
@@ -130,7 +130,7 @@ const download = async () => {
|
||||
mode: 'readwrite'
|
||||
})
|
||||
filesystemdl(sel, handle).then(() => {
|
||||
store.selected.clear()
|
||||
documentStore.selected.clear()
|
||||
})
|
||||
return
|
||||
} catch (e) {
|
||||
@@ -140,7 +140,7 @@ const download = async () => {
|
||||
// Otherwise, zip and download
|
||||
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
|
||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||
store.selected.clear()
|
||||
documentStore.selected.clear()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -152,4 +152,3 @@ const download = async () => {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -39,10 +39,10 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { loginUser } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
|
||||
const confirmLoading = ref<boolean>(false)
|
||||
const store = useMainStore()
|
||||
const store = useDocumentStore()
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
@@ -99,4 +99,3 @@ const login = async () => {
|
||||
height: 1em;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { connect, uploadUrl } from '@/repositories/WS';
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { collator } from '@/utils';
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
|
||||
const fileInput = ref()
|
||||
const folderInput = ref()
|
||||
const store = useMainStore()
|
||||
const documentStore = useDocumentStore()
|
||||
const props = defineProps({
|
||||
path: Array<string>
|
||||
})
|
||||
@@ -75,7 +75,7 @@ const uploadFiles = (infiles: File[]) => {
|
||||
const uploadCloudFiles = (files: CloudFile[]) => {
|
||||
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
||||
if (dotfiles.length) {
|
||||
store.error = "Won't upload dotfiles"
|
||||
documentStore.error = "Won't upload dotfiles"
|
||||
console.log("Dotfiles omitted", dotfiles)
|
||||
files = files.filter(f => !f.cloudName.includes('/.'))
|
||||
}
|
||||
@@ -171,13 +171,13 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
||||
open(ev: Event) { resolve(ws) },
|
||||
error(ev: Event) {
|
||||
console.error('Upload socket error', ev)
|
||||
store.error = 'Upload socket error'
|
||||
documentStore.error = 'Upload socket error'
|
||||
},
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev!.data)
|
||||
if ('error' in res) {
|
||||
console.error('Upload socket error', res.error)
|
||||
store.error = res.error.message
|
||||
documentStore.error = res.error.message
|
||||
return
|
||||
}
|
||||
if (res.status === 'ack') {
|
||||
@@ -302,4 +302,3 @@ span {
|
||||
.position { min-width: 4em }
|
||||
.speed { min-width: 4em }
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -23,19 +23,11 @@ export class Doc {
|
||||
constructor(props: Partial<DocProps> = {}) { Object.assign(this, props) }
|
||||
get name() { return this._name }
|
||||
set name(name: string) {
|
||||
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||
this._name = name
|
||||
this.haystack = haystackFormat(name)
|
||||
}
|
||||
get sizedisp(): string { return formatSize(this.size) }
|
||||
get modified(): string { return formatUnixDate(this.mtime) }
|
||||
get url(): string {
|
||||
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||
return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
}
|
||||
get urlrouter(): string {
|
||||
return this.url.replace(/^\/#/, '')
|
||||
}
|
||||
}
|
||||
export type errorEvent = {
|
||||
error: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMainStore } from "@/stores/main"
|
||||
import { useDocumentStore } from "@/stores/documents"
|
||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
||||
|
||||
export const controlUrl = '/api/control'
|
||||
@@ -12,7 +12,7 @@ let wsWatch = null as WebSocket | null
|
||||
export const loadSession = () => {
|
||||
const s = localStorage['cista-files']
|
||||
if (!s) return false
|
||||
const store = useMainStore()
|
||||
const store = useDocumentStore()
|
||||
try {
|
||||
tree = JSON.parse(s)
|
||||
store.updateRoot(tree)
|
||||
@@ -39,7 +39,7 @@ export const watchConnect = () => {
|
||||
clearTimeout(watchTimeout)
|
||||
watchTimeout = null
|
||||
}
|
||||
const store = useMainStore()
|
||||
const store = useDocumentStore()
|
||||
if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
|
||||
console.log(store.error)
|
||||
|
||||
@@ -81,7 +81,7 @@ export const watchDisconnect = () => {
|
||||
let watchTimeout: any = null
|
||||
|
||||
const watchReconnect = (event: MessageEvent) => {
|
||||
const store = useMainStore()
|
||||
const store = useDocumentStore()
|
||||
if (store.connected) {
|
||||
console.warn("Disconnected from server", event)
|
||||
store.connected = false
|
||||
@@ -114,7 +114,7 @@ const handleWatchMessage = (event: MessageEvent) => {
|
||||
}
|
||||
|
||||
function handleRootMessage({ root }: { root: FileEntry[] }) {
|
||||
const store = useMainStore()
|
||||
const store = useDocumentStore()
|
||||
console.log('Watch root', root)
|
||||
store.updateRoot(root)
|
||||
tree = root
|
||||
@@ -122,7 +122,7 @@ function handleRootMessage({ root }: { root: FileEntry[] }) {
|
||||
}
|
||||
|
||||
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
const store = useMainStore()
|
||||
const store = useDocumentStore()
|
||||
const update = updateData.update
|
||||
console.log('Watch update', update)
|
||||
if (!tree) return console.error('Watch update before root')
|
||||
@@ -146,7 +146,7 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
}
|
||||
|
||||
function handleError(msg: errorEvent) {
|
||||
const store = useMainStore()
|
||||
const store = useDocumentStore()
|
||||
if (msg.error.code === 401) {
|
||||
store.user.isOpenLoginModal = true
|
||||
store.user.isLoggedIn = false
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -14,20 +13,15 @@ type User = {
|
||||
isLoggedIn: boolean
|
||||
}
|
||||
|
||||
export const useMainStore = defineStore({
|
||||
id: 'main',
|
||||
export const useDocumentStore = defineStore({
|
||||
id: 'documents',
|
||||
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,
|
||||
@@ -35,9 +29,6 @@ export const useMainStore = defineStore({
|
||||
isOpenLoginModal: false
|
||||
} as User
|
||||
}),
|
||||
persist: {
|
||||
paths: ['prefs'],
|
||||
},
|
||||
actions: {
|
||||
updateRoot(root: FileEntry[]) {
|
||||
const docs = []
|
||||
@@ -72,16 +63,22 @@ export const useMainStore = defineStore({
|
||||
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') },
|
||||
isUserLogged(): boolean {
|
||||
return this.user.isLoggedIn
|
||||
},
|
||||
recentDocuments(): Doc[] {
|
||||
const ret = [...this.document]
|
||||
ret.sort((a, b) => b.mtime - a.mtime)
|
||||
return ret
|
||||
},
|
||||
largeDocuments(): Doc[] {
|
||||
const ret = [...this.document]
|
||||
ret.sort((a, b) => b.size - a.size)
|
||||
return ret
|
||||
},
|
||||
selectedFiles(): SelectedItems {
|
||||
const selected = this.selected
|
||||
const found = new Set<FUID>()
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -10,12 +10,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchEffect, ref, computed } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import Router from '@/router/index'
|
||||
import { needleFormat, localeIncludes, collator } from '@/utils';
|
||||
import { sorted } from '@/utils/docsort';
|
||||
|
||||
const store = useMainStore()
|
||||
const documentStore = useDocumentStore()
|
||||
const fileExplorer = ref()
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
@@ -25,25 +24,19 @@ const documents = computed(() => {
|
||||
const loc = props.path.join('/')
|
||||
const query = props.query
|
||||
// List the current location
|
||||
if (!query) return sorted(
|
||||
store.document.filter(doc => doc.loc === loc),
|
||||
store.prefs.sortListing,
|
||||
)
|
||||
if (!query) return documentStore.document.filter(doc => doc.loc === loc)
|
||||
// Find up to 100 newest documents that match the search
|
||||
const needle = needleFormat(query)
|
||||
let limit = 100
|
||||
let docs = []
|
||||
for (const doc of store.recentDocuments) {
|
||||
for (const doc of documentStore.recentDocuments) {
|
||||
if (localeIncludes(doc.haystack, needle)) {
|
||||
docs.push(doc)
|
||||
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) ||
|
||||
@@ -60,7 +53,6 @@ const documents = computed(() => {
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
store.fileExplorer = fileExplorer.value
|
||||
store.query = props.query
|
||||
documentStore.fileExplorer = fileExplorer.value
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -29,7 +29,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.zi.fi/Vasanko/cista-storage"
|
||||
Homepage = ""
|
||||
|
||||
[project.scripts]
|
||||
cista = "cista.__main__:main"
|
||||
@@ -40,18 +40,20 @@ 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",
|
||||
]
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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