Compare commits

..

No commits in common. "4c51029c9fedef275b5fa0334efb9e05bc08bb7c" and "54d6ea6332e8fe09e5e73973ef3c492dea9d7fb2" have entirely different histories.

13 changed files with 115 additions and 200 deletions

View File

@ -3,39 +3,46 @@
:root {
--primary-color: #000;
--primary-background: #ddd;
--header-background: var(--soft-color);
--header-background: #246;
--header-color: #ccc;
--input-background: #fff;
--input-color: #000;
--primary-color: #000;
--soft-color: #146;
--accent-color: #f80;
--transition-time: 0.2s;
/* The following are overridden by responsive layouts */
--root-font-size: 1rem;
--header-font-size: 1rem;
--header-height: calc(6.5 * var(--header-font-size));
--header-height: calc(8 * var(--header-font-size));
}
@media (prefers-color-scheme: dark) {
:root {
--primary-color: #ddd;
--primary-background: var(--soft-color);
--primary-background: #003;
--header-background: #000;
--header-color: #ccc;
--input-background: var(--soft-color);
--input-color: #ddd;
}
}
@media screen and (max-width: 600px) {
.size,
.modified,
.summary {
.modified {
display: none;
}
}
@media screen and (min-width: 1000px) {
@media screen and (orientation: landscape) and (min-width: 1200px) {
/* Breadcrumbs and buttons side by side */
header {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: end;
}
header .breadcrumb {
font-size: 1.7em;
flex-shrink: 10;
}
}
@media screen and (min-width: 800px) and (--webkit-min-device-pixel-ratio: 2) {
:root {
--root-font-size: calc(8px + 8 * 100vw / 1000);
--root-font-size: calc(16 * 100vw / 800);
}
header .buttons:has(input[type='search']) > div {
display: none;
@ -44,51 +51,20 @@
display: inherit;
}
}
@media screen and (min-width: 2000px) {
@media screen and (min-width: 1600px) and (--webkit-min-device-pixel-ratio: 3) {
:root {
--root-font-size: 1.5rem;
--root-font-size: 2rem;
}
}
/* Low (landscape) screens: smaller header */
@media screen and (max-height: 600px) {
:root {
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
--root-font-size: 0.8rem;
}
header .breadcrumb > * {
padding-top: calc(8 + 8 * 100vh / 600) !important;
padding-bottom: calc(8 + 8 * 100vh / 600) !important;
--header-font-size: calc(16 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
}
}
@media screen and (max-height: 300px) {
:root {
--header-font-size: 15px; /* Don't go smaller than this, no benefit */
--header-font-size: 0.5rem; /* Don't go smaller than this, no benefit */
--header-height: calc(1.75 * 16px);
--root-font-size: 0.6rem;
}
header .breadcrumb > * {
padding-top: 14px !important;
padding-bottom: 14px !important;
}
}
@media screen and (orientation: landscape) and (min-width: 700px) {
/* Breadcrumbs and buttons side by side */
:root {
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
}
header {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: end;
}
header .breadcrumb {
flex-shrink: 1;
}
header .breadcrumb > * {
flex-shrink: 1;
padding-top: 1rem !important;
padding-bottom: 1rem !important;
}
}
@media print {
@ -143,7 +119,6 @@
}
}
html {
font-size: var(--root-font-size);
overflow: hidden;
}
/* Hide scrollbar for all browsers */
@ -223,7 +198,7 @@ main {
overflow-y: scroll;
}
.spacer { flex-grow: 1 }
.smallgap { flex-shrink: 1; width: 2em }
.smallgap { margin-left: 2em }
[data-tooltip]:hover:after {
z-index: 101;
@ -260,9 +235,3 @@ main {
opacity: 0;
}
}
.error-message {
padding: .5em;
font-weight: bold;
background: var(--accent-color);
color: #000;
}

View File

@ -129,8 +129,8 @@
</td>
</tr>
</template>
<tr class="summary" v-if="props.documents.length > 1">
<td colspan="3" class="right">{{props.documents.length}} items</td>
<tr class="summary">
<td colspan="3" class="right">{{props.documents.length}} items shown:</td>
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
<td class="menu"></td>
</tr>
@ -140,7 +140,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import { ref, computed, watchEffect, onBeforeUpdate } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import type { Document } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
@ -442,7 +442,6 @@ table td {
}
}
thead tr {
font-size: var(--header-font-size);
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
color: #000;
box-shadow: 0 0 .2rem black;
@ -511,7 +510,4 @@ tbody .selection input {
.loc {
color: #888;
}
.summary {
color: #888;
}
</style>

View File

@ -1,10 +1,6 @@
<template>
<nav class="headermain">
<div class="buttons">
<template v-if="documentStore.error">
<div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton />
<SvgButton
name="create-folder"
@ -27,6 +23,17 @@
<SvgButton name="cog" @click="settingsMenu" />
</div>
</nav>
<context-menu v-model:show="showMenu">
<context-menu-item label="Simple item" @click="onMenuClick(1)" />
<context-menu-sperator /><!--use this to add sperator-->
<context-menu-group label="Menu with child">
<context-menu-item label="Item1" @click="onMenuClick(2)" />
<context-menu-item label="Item2" @click="onMenuClick(3)" />
<context-menu-group label="Child with v-for 50">
<context-menu-item v-for="index of 50" :key="index" :label="'Item3-'+index" @click="onLoopMenuClick(index)" />
</context-menu-group>
</context-menu-group>
</context-menu>
</template>
<script setup lang="ts">
@ -57,16 +64,12 @@ const toggleSearchInput = () => {
const settingsMenu = (e: Event) => {
// show the context menu
const items = []
if (documentStore.user.isLoggedIn) {
items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() })
} else {
items.push({ label: 'Login', onClick: () => documentStore.loginDialog() })
}
ContextMenu.showContextMenu({
// @ts-ignore
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom,
items,
items: [
{ label: "Logout", onClick: () => { documentStore.logout() } },
]
})
}
@ -88,8 +91,8 @@ defineExpose({
flex-shrink: 1;
}
input[type='search'] {
background: var(--input-background);
color: var(--input-color);
background: var(--primary-background);
color: var(--primary-color);
border: 0;
border-radius: 0.1em;
padding: 0.5em;

View File

@ -146,7 +146,7 @@ const download = async () => {
<style>
.select-text {
color: var(--accent-color);
white-space: nowrap;
text-wrap: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -1,5 +1,5 @@
<template>
<ModalDialog v-if="store.user.isOpenLoginModal" title="Authentication required" @blur="store.user.isOpenLoginModal = false">
<ModalDialog v-if="store.user.isOpenLoginModal" title="Authentication required">
<form @submit.prevent="login">
<div class="login-container">
<label for="username">Username:</label>
@ -7,8 +7,6 @@
id="username"
name="username"
autocomplete="username"
spellcheck="false"
autocorrect="off"
required
v-model="loginForm.username"
/>
@ -18,14 +16,12 @@
name="password"
type="password"
autocomplete="current-password"
spellcheck="false"
autocorrect="off"
required
v-model="loginForm.password"
/>
</div>
<h3 class="error-text">
{{ loginForm.error || '\u00A0' }}
<h3 v-if="loginForm.error.length > 0" class="error-text">
{{ loginForm.error }}
</h3>
<div class="dialog-buttons">
<div class="spacer"></div>
@ -37,13 +33,21 @@
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { loginUser } from '@/repositories/User'
import { loginUser, logoutUser } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useDocumentStore } from '@/stores/documents'
const confirmLoading = ref<boolean>(false)
const store = useDocumentStore()
const logout = async () => {
try {
await logoutUser()
} finally {
location.reload()
}
}
const loginForm = reactive({
username: '',
password: '',
@ -55,10 +59,13 @@ const login = async () => {
loginForm.error = ''
confirmLoading.value = true
const msg = await loginUser(loginForm.username, loginForm.password)
store.login(msg.data.username, !!msg.data.privileged)
console.log('Logged in', msg)
store.login(msg.username, !!msg.privileged)
} catch (error) {
const httpError = error as ISimpleError
loginForm.error = httpError.message || '🛑 Unknown error'
if (httpError.name) {
loginForm.error = httpError.message
}
} finally {
confirmLoading.value = false
}
@ -80,22 +87,18 @@ const login = async () => {
align-items: center;
}
.button-login {
color: #fff;
background: var(--soft-color);
cursor: pointer;
font-weight: bold;
border: 0;
border-radius: .5rem;
padding: .5rem 2rem;
padding: .5rem;
margin-left: auto;
transition: all var(--transition-time) linear;
background-color: var(--accent-color);
color: var(--primary-color);
}
.button-login:hover, .button-login:focus {
background: var(--accent-color);
box-shadow: 0 0 .3rem #000;
.ant-btn-primary:not(:disabled):hover {
background-color: var(--blue-color);
}
.error-text {
color: var(--red-color);
height: 1em;
}
</style>

View File

@ -23,18 +23,15 @@ const props = withDefaults(
title: ''
}
)
const show = () => {
dialog.value!.showModal()
}
defineExpose({ show })
onMounted(() => {
show()
dialog.value!.showModal()
})
</script>
<style>
/* Style for the background */
dialog::backdrop {
body:has(dialog[open])::before {
content: '';
display: block;
position: fixed;
@ -43,7 +40,7 @@ dialog::backdrop {
width: 100%;
height: 100%;
background: #0008;
backdrop-filter: blur(0.4em);
backdrop-filter: blur(0.2em);
z-index: 1000;
}
@ -53,7 +50,6 @@ dialog[open] {
color: black;
display: block;
border: none;
font-size: 1.2rem;
border-radius: 0.5rem;
box-shadow: 0.2rem 0.2rem 1rem #000;
padding: 1rem;
@ -62,13 +58,11 @@ dialog[open] {
left: 0;
z-index: 1001;
}
input {
font: inherit;
}
dialog[open] > h1 {
background: var(--soft-color);
color: #fff;
font-size: 1.2rem;
background: var(--accent-color);
color: black;
font-size: 1rem;
margin: -1rem -1rem 0 -1rem;
padding: 0.5rem 1rem 0.5rem 1rem;
}

View File

@ -12,7 +12,7 @@ class ClientClass {
try {
msg = await res.json()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
throw new SimpleError(res.status, `HTTP ${res.status} ${res.statusText}`)
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg

View File

@ -15,41 +15,13 @@ export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEv
return webSocket
}
export const watchConnect = () => {
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
watchTimeout = null
}
const store = useDocumentStore()
if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
console.log(store.error)
export const watchConnect = async () => {
wsWatch = connect(watchUrl, {
open() { console.log("Connected to", watchUrl)},
message: handleWatchMessage,
close: watchReconnect,
})
wsWatch.addEventListener("message", event => {
if (store.connected) return
const msg = JSON.parse(event.data)
if ('error' in msg) {
if (msg.error.code === 401) {
store.user.isLoggedIn = false
store.user.isOpenLoginModal = true
} else {
store.error = msg.error.message
}
return
}
if ("server" in msg) {
console.log('Connected to backend', msg)
store.connected = true
reconnectDuration = 500
store.error = ''
if (msg.user) store.login(msg.user.username, msg.user.privileged)
else if (store.isUserLogged) store.logout()
if (!msg.server.public && !msg.user) store.user.isOpenLoginModal = true
}
})
await wsWatch
}
export const watchDisconnect = () => {
@ -58,24 +30,35 @@ export const watchDisconnect = () => {
wsWatch = null
}
let watchTimeout: any = null
const watchReconnect = (event: MessageEvent) => {
const store = useDocumentStore()
if (store.connected) {
console.warn("Disconnected from server", event)
store.connected = false
store.error = 'Reconnecting...'
}
reconnectDuration = Math.min(5000, reconnectDuration + 500)
// The server closes the websocket after errors, so we need to reopen it
if (watchTimeout !== null) clearTimeout(watchTimeout)
watchTimeout = setTimeout(watchConnect, reconnectDuration)
setTimeout(() => {
wsWatch = connect(watchUrl, {
message: handleWatchMessage,
close: watchReconnect,
})
console.log("Attempting to reconnect...")
}, reconnectDuration)
}
const handleWatchMessage = (event: MessageEvent) => {
const store = useDocumentStore()
const msg = JSON.parse(event.data)
if ('error' in msg) {
if (msg.error.code === 401) {
store.user.isLoggedIn = false
store.user.isOpenLoginModal = true
} else {
store.error = msg.error.message
}
}
switch (true) {
case !!msg.root:
handleRootMessage(msg)
@ -96,6 +79,9 @@ const handleWatchMessage = (event: MessageEvent) => {
function handleRootMessage({ root }: { root: DirEntry }) {
const store = useDocumentStore()
console.log('Watch root', root)
reconnectDuration = 500
store.connected = true
store.user.isLoggedIn = true
store.updateRoot(root)
tree = root
}
@ -110,9 +96,8 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
delete node.dir[elem.name]
break // Deleted elements can't have further children
}
if (elem.name) {
if (elem.name !== undefined) {
// @ts-ignore
console.log(node, elem.name)
node = node.dir[elem.name] ||= {}
}
if (elem.key !== undefined) node.key = elem.key

View File

@ -8,8 +8,6 @@ import type {
import { formatSize, formatUnixDate, haystackFormat } from '@/utils'
import { defineStore } from 'pinia'
import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User'
import { watchConnect } from '@/repositories/WS'
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
type DirectoryData = {
@ -110,14 +108,10 @@ export const useDocumentStore = defineStore({
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()
const res = await fetch('/logout', { method: 'POST' })
if (!res.ok) throw Error(`Logout failed: ${res.statusText}`)
this.$reset()
history.go() // Reload page
}

View File

@ -1,11 +1,10 @@
import asyncio
import typing
from secrets import token_bytes
import msgspec
from sanic import Blueprint
from cista import __version__, config, watching
from cista import watching
from cista.fileio import FileServer
from cista.protocol import ControlTypes, FileRange, StatusMsg
from cista.util.apphelpers import asend, websocket_wrapper
@ -84,27 +83,9 @@ async def control(req, ws):
@bp.websocket("watch")
@websocket_wrapper
async def watch(req, ws):
await ws.send(
msgspec.json.encode(
{
"server": {
"name": "Cista", # Should be configurable
"version": __version__,
"public": config.config.public,
},
"user": {
"username": req.ctx.username,
"privileged": req.ctx.user.privileged,
}
if req.ctx.user
else None,
}
).decode()
)
uuid = token_bytes(16)
try:
with watching.tree_lock:
q = watching.pubsub[uuid] = asyncio.Queue()
q = watching.pubsub[ws] = asyncio.Queue()
# Init with disk usage and full tree
await ws.send(watching.format_du())
await ws.send(watching.format_tree())
@ -112,4 +93,4 @@ async def watch(req, ws):
while True:
await ws.send(await q.get())
finally:
del watching.pubsub[uuid]
del watching.pubsub[ws]

View File

@ -38,10 +38,8 @@ async def main_stop(app, loop):
async def use_session(req):
req.ctx.session = session.get(req)
try:
req.ctx.username = req.ctx.session["username"]
req.ctx.user = config.config.users[req.ctx.session["username"]] # type: ignore
except (AttributeError, KeyError, TypeError):
req.ctx.username = None
req.ctx.user = None
# CSRF protection
if req.method == "GET" and req.headers.upgrade != "websocket":

View File

@ -45,7 +45,7 @@ class Rm(ControlBase):
sel = [root / filename.sanitize(p) for p in self.sel]
for p in sel:
if p.is_dir():
shutil.rmtree(p)
shutil.rmtree(p, ignore_errors=True)
else:
p.unlink()
@ -147,9 +147,9 @@ DirList = dict[str, FileEntry | DirEntry]
class UpdateEntry(msgspec.Struct, omit_defaults=True):
"""Updates the named entry in the tree. Fields that are set replace old values. A list of entries recurses directories."""
name: str
key: str
name: str = ""
deleted: bool = False
key: str | None = None
size: int | None = None
mtime: int | None = None
dir: DirList | None = None

View File

@ -6,7 +6,6 @@ from pathlib import Path, PurePosixPath
import inotify.adapters
import msgspec
from sanic.log import logging
from cista import config
from cista.fileio import fuid
@ -30,7 +29,7 @@ disk_usage = None
def watcher_thread(loop):
global disk_usage, rootpath
global disk_usage
while True:
rootpath = config.config.path
@ -39,7 +38,6 @@ def watcher_thread(loop):
with tree_lock:
# Initialize the tree from filesystem
tree[""] = walk(rootpath)
print(" ".join(tree[""].dir.keys()))
msg = format_tree()
if msg != old:
asyncio.run_coroutine_threadsafe(broadcast(msg), loop)
@ -69,8 +67,8 @@ def watcher_thread(loop):
try:
update(path.relative_to(rootpath), loop)
except Exception as e:
print("Watching error", e, path, rootpath)
raise
print("Watching error", e)
break
i = None # Free the inotify object
@ -122,8 +120,6 @@ def walk(path: Path) -> DirEntry | FileEntry | None:
def update(relpath: PurePosixPath, loop):
"""Called by inotify updates, check the filesystem and broadcast any changes."""
if rootpath is None or relpath is None:
print("ERROR", rootpath, relpath)
new = walk(rootpath / relpath)
with tree_lock:
update = update_internal(relpath, new)
@ -164,7 +160,7 @@ def update_internal(
# Update parents
update = []
for name, entry in elems[:-1]:
u = UpdateEntry(name, entry.key)
u = UpdateEntry(name)
if szdiff:
entry.size += szdiff
u.size = entry.size
@ -174,14 +170,14 @@ def update_internal(
# The last element is the one that changed
name, entry = elems[-1]
parent = elems[-2][1] if len(elems) > 1 else tree
u = UpdateEntry(name, new.key if new else entry.key)
u = UpdateEntry(name)
if new:
parent[name] = new
if u.size != new.size:
u.size = new.size
if u.mtime != new.mtime:
u.mtime = new.mtime
if isinstance(new, DirEntry) and u.dir != new.dir:
if isinstance(new, DirEntry) and u.dir == new.dir:
u.dir = new.dir
else:
del parent[name]
@ -191,12 +187,8 @@ def update_internal(
async def broadcast(msg):
try:
for queue in pubsub.values():
queue.put_nowait(msg)
except Exception:
# Log because asyncio would silently eat the error
logging.exception("Broadcast error")
await queue.put_nowait(msg)
async def start(app, loop):