19 Commits

Author SHA1 Message Date
Leo Vasanko
62388eb555 Fix preview images that need rotation 2023-11-18 12:04:35 -08:00
Leo Vasanko
53778543bf Remove debug 2023-11-18 11:47:01 -08:00
Leo Vasanko
8dda230510 Add PIL dependency 2023-11-18 11:40:34 -08:00
Leo Vasanko
696e3ab568 Implement media preview thumbnails for Gallery 2023-11-18 11:38:25 -08:00
Leo Vasanko
85ac12ad33 Fix empty folder / folder missing for empty folders at root 2023-11-18 10:22:07 -08:00
Leo Vasanko
e56cc47105 Large number of keyboard navigation and other fixes. 2023-11-18 10:15:13 -08:00
Leo Vasanko
ebbd96bc94 Global shortcut keys tuning. 2023-11-18 07:56:53 -08:00
Leo Vasanko
a9b6d04361 Remove defineProps imports (it is a compiler macro now). 2023-11-18 07:47:55 -08:00
Leo Vasanko
5808fe17ad Fix cursor handling 2023-11-18 07:47:34 -08:00
Leo Vasanko
671359e327 Search placeholder 2023-11-18 07:39:08 -08:00
Leo Vasanko
ba9495eb65 File explorer CSS, avoid modified/size being cut 2023-11-18 07:33:50 -08:00
Leo Vasanko
de482afd60 Improved breadcrumb scaling 2023-11-18 07:00:55 -08:00
Leo Vasanko
a547052e29 Added header eye button for gallery toggle 2023-11-18 07:00:46 -08:00
Leo Vasanko
07c2ff4c15 Keyboard sort by 1-2-3 supplemented by the key left of them for default sort. 2023-11-18 06:32:25 -08:00
Leo Vasanko
e20b04189f Refactoring cursor to be stored in store as key only. A few issues remain. 2023-11-17 19:44:18 -08:00
Leo Vasanko
8da141744e Implemented Gallery view for media files. 2023-11-17 18:32:24 -08:00
Leo Vasanko
11887edde3 Skip any symlinks while scanning. Stats on how long a scan took. 2023-11-17 17:49:35 -08:00
Leo Vasanko
034c6fdea9 Fixed header and breadcrumb layout and issues. 2023-11-17 16:16:53 -08:00
Leo Vasanko
c5083f0f2b Correct error page rendering via Sanic. 2023-11-17 09:20:14 -08:00
20 changed files with 780 additions and 160 deletions

View File

@@ -16,7 +16,7 @@ from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger
from stream_zip import ZIP_AUTO, stream_zip
from cista import auth, config, session, watching
from cista import auth, config, preview, session, watching
from cista.api import bp
from cista.util.apphelpers import handle_sanic_exception
@@ -25,6 +25,7 @@ sanic.helpers._ENTITY_HEADERS = frozenset()
app = Sanic("cista", strict_slashes=True)
app.blueprint(auth.bp)
app.blueprint(preview.bp)
app.blueprint(bp)
app.exception(Exception)(handle_sanic_exception)

52
cista/preview.py Normal file
View File

@@ -0,0 +1,52 @@
import asyncio
import io
from pathlib import PurePosixPath
from urllib.parse import unquote
from PIL import Image
from sanic import Blueprint, raw
from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger
from cista import config
from cista.util.filename import sanitize
bp = Blueprint("preview", url_prefix="/preview")
@bp.get("/<path:path>")
async def preview(req, path):
"""Preview a file"""
width = int(req.query_string) if req.query_string else 1024
rel = PurePosixPath(sanitize(unquote(path)))
path = config.config.path / rel
if not path.is_file():
raise NotFound("File not found")
size = path.lstat().st_size
if size > 20 * 10**6:
raise Forbidden("File too large")
img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, process_image, path, width
)
return raw(img, content_type="image/webp")
def process_image(path, maxsize):
img = Image.open(path)
w, h = img.size
img.thumbnail((min(w, maxsize), min(h, maxsize)))
# Fix rotation based on EXIF data
try:
rotate_values = {3: 180, 6: 270, 8: 90}
exif = img._getexif()
if exif:
orientation = exif.get(274)
if orientation in rotate_values:
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
img = img.rotate(rotate_values[orientation], expand=True)
except Exception as e:
logger.error(f"Error rotating preview image: {e}")
# Save as webp
imgdata = io.BytesIO()
img.save(imgdata, format="webp", quality=70, method=6)
return imgdata.getvalue()

View File

@@ -41,9 +41,7 @@ async def handle_sanic_exception(request, e):
res.cookies.add_cookie("message", message, max_age=5)
return res
# Otherwise use Sanic's default error page
res = errorpages.HTMLRenderer(request, e, debug=request.app.debug).full()
res.status = status # Unfortunately Sanic <23.12 doesn't set this
return res
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).render()
def websocket_wrapper(handler):

View File

@@ -6,7 +6,7 @@ import time
from contextlib import suppress
from os import stat_result
from pathlib import Path, PurePosixPath
from stat import S_ISDIR
from stat import S_ISDIR, S_ISREG
import msgspec
from natsort import humansorted, natsort_keygen, ns
@@ -144,8 +144,12 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
if f.name.startswith("."):
continue # No dotfiles
with suppress(FileNotFoundError):
s = f.stat()
li.append((int(not S_ISDIR(s.st_mode)), f.name, s))
s = f.lstat()
isfile = S_ISREG(s.st_mode)
isdir = S_ISDIR(s.st_mode)
if not isfile and not isdir:
continue
li.append((int(isfile), f.name, s))
# Build the tree as a list of FileEntries
for [_, name, s] in humansorted(li):
sub = walk(rel / name, stat=s)
@@ -155,7 +159,9 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
entry.size += child.size
except FileNotFoundError:
pass # Things may be rapidly in motion
except OSError:
except OSError as e:
if e.errno == 13: # Permission denied
pass
logger.error(f"Watching {path=}: {e!r}")
return ret
@@ -309,9 +315,13 @@ def watcher_inotify(loop):
def watcher_poll(loop):
"""Polling version of the watcher thread."""
while not quit.is_set():
t0 = time.perf_counter()
update_root(loop)
update_space(loop)
quit.wait(2.0)
dur = time.perf_counter() - t0
if dur > 1.0:
logger.debug(f"Reading the full file list took {dur:.1f}s")
quit.wait(0.1 + 8 * dur)
async def start(app, loop):

View File

@@ -21,6 +21,7 @@ import { useMainStore } from '@/stores/main'
import { computed } from 'vue'
import Router from '@/router/index'
import type { SortOrder } from './utils/docsort'
interface Path {
path: string
@@ -57,16 +58,18 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
if (
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
(c && event.code === 'Space')
) {
event.preventDefault()
if (!input) event.preventDefault()
}
return
}
//console.log("key pressed", event)
// For up/down implement custom fast repeat
if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1
else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1
/// Long if-else machina for all keys we handle here
let arrow = ''
if (event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
// Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
headerMain.value!.toggleSearchInput()
@@ -75,21 +78,30 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if (keyup && !input && event.key === '/') {
headerMain.value!.toggleSearchInput()
}
// Globally close search on Escape
// Globally close search, clear errors on Escape
else if (keyup && event.key === 'Escape') {
store.error = ''
headerMain.value!.closeSearch(event)
store.focusBreadcrumb()
}
// Select all (toggle); keydown to prevent builtin
else if (!input && keyup && event.key === 'Backspace') {
Router.back()
}
// Select all (toggle); keydown to precede and prevent builtin
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
fileExplorer.toggleSelectAll()
}
// Keys 1-3 to sort columns
// G toggles Gallery
else if (!input && keyup && event.key === 'g') {
store.prefs.gallery = !store.prefs.gallery
}
// Keys Backquote-1-2-3 to sort columns
else if (
!input &&
keyup &&
(event.key === '1' || event.key === '2' || event.key === '3')
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3')
) {
fileExplorer.toggleSortColumn(+event.key)
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
}
// Rename
else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
@@ -99,28 +111,26 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if (c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey)
fileExplorer.cursorSelect()
} else return
event.preventDefault()
if (!vert) {
if (timer) {
clearTimeout(timer) // Good for either timeout or interval
timer = null
}
return
}
if (!timer) {
else return
/// We are handling this!
event.preventDefault()
if (timer) {
clearTimeout(timer) // Good for either timeout or interval
timer = null
}
let f: any
switch (arrow) {
case 'up': f = () => fileExplorer.up(event); break
case 'down': f = () => fileExplorer.down(event); break
case 'left': f = () => fileExplorer.left(event); break
case 'right': f = () => fileExplorer.right(event); break
}
if (f && !keyup) {
// Initial move, then t0 delay until repeats at tr intervals
const select = event.shiftKey
fileExplorer.cursorMove(vert, select)
const t0 = 200,
tr = 30
timer = setTimeout(
() =>
(timer = setInterval(() => {
fileExplorer.cursorMove(vert, select)
}, tr)),
t0 - tr
)
const t0 = 200, tr = event.altKey ? 20 : 100
f()
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr)
}
}
onMounted(() => {
@@ -133,4 +143,3 @@ onUnmounted(() => {
})
export type { Path }
</script>
@/stores/main

View File

@@ -14,7 +14,7 @@
/* 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: 4rem;
}
@media (prefers-color-scheme: dark) {
:root {
@@ -37,12 +37,6 @@
:root {
--root-font-size: calc(8px + 8 * 100vw / 1000);
}
header .buttons:has(input[type='search']) > div {
display: none;
}
header .buttons > div:has(input[type='search']) {
display: inherit;
}
}
@media screen and (min-width: 2000px) {
:root {
@@ -54,6 +48,7 @@
:root {
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
--root-font-size: 0.8rem;
--header-height: 2rem;
}
header .breadcrumb > * {
padding-top: calc(8 + 8 * 100vh / 600) !important;
@@ -78,17 +73,13 @@
}
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;
header .headermain { order: 1; }
header .breadcrumb { align-self: stretch; }
header .action-button {
width: 2em;
height: 2em;
}
}
@media print {
@@ -101,7 +92,7 @@
nav,
.menu,
.rename-button {
display: none;
display: none !important;
}
.breadcrumb > a {
color: black !important;
@@ -122,6 +113,7 @@
padding-bottom: 0 !important;
}
thead tr {
font-size: 1rem !important;
position: static !important;
background: none !important;
border-bottom: 1pt solid black !important;
@@ -142,6 +134,9 @@
left: 0;
}
}
* {
box-sizing: border-box;
}
html {
font-size: var(--root-font-size);
overflow: hidden;

View File

@@ -2,8 +2,8 @@
<nav
class="breadcrumb"
aria-label="Breadcrumb"
@keyup.left.stop="move(-1)"
@keyup.right.stop="move(1)"
@keydown.left.stop="move(-1)"
@keydown.right.stop="move(1)"
@keyup.enter="move(0)"
@focus=focusCurrent
tabindex=0
@@ -12,6 +12,8 @@
:ref="el => setLinkRef(0, el)"
:class="{ current: !!isCurrent(0) }"
:aria-current="isCurrent(0)"
@click.prevent="navigate(0)"
title="/"
>
<component :is="home" />
</a>
@@ -21,6 +23,7 @@
:aria-current="isCurrent(index + 1)"
@click.prevent="navigate(index + 1)"
:ref="el => setLinkRef(index + 1, el)"
:title="`/${longest.slice(0, index + 1).join('/')}`"
>{{ location }}</a>
</template>
</nav>
@@ -101,31 +104,35 @@ watchEffect(() => {
--breadcrumb-transtime: 0.3s;
}
.breadcrumb {
flex: 1 1 auto;
display: flex;
list-style: none;
min-width: 20%;
max-width: 100%;
min-height: 2em;
margin: 0;
padding: 0 1em 0 0;
}
.breadcrumb > a {
flex: 0 4 auto;
display: flex;
align-items: center;
margin: 0 -0.5em 0 -0.5em;
padding: 0;
max-width: 8em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1.5em;
color: var(--breadcrumb-color);
padding: 0.3em 1.5em;
clip-path: polygon(0 0, 1em 50%, 0 100%, 100% 100%, 100% 0, 0 0);
transition: all var(--breadcrumb-transtime);
}
.breadcrumb a:first-child {
margin-left: 0;
padding-left: .2em;
.breadcrumb > a:first-child {
flex: 0 0 auto;
padding-left: 1.5em;
padding-right: 1.7em;
clip-path: none;
}
.breadcrumb a:last-child {
max-width: none;
.breadcrumb > a:last-child {
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
@@ -136,7 +143,7 @@ watchEffect(() => {
0 0
);
}
.breadcrumb a:only-child {
.breadcrumb > a:only-child {
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
@@ -148,9 +155,9 @@ watchEffect(() => {
}
.breadcrumb svg {
/* FIXME: Custom positioning to align it well; needs proper solution */
padding-left: 0.8em;
width: 1.3em;
height: 1.3em;
margin: -.5em;
fill: var(--breadcrumb-color);
transition: fill var(--breadcrumb-transtime);
}
@@ -170,6 +177,6 @@ watchEffect(() => {
}
.breadcrumb a:hover { color: var(--breadcrumb-hover-color) }
.breadcrumb a:hover svg { fill: var(--breadcrumb-hover-color) }
.breadcrumb a.current { color: var(--accent-color) }
.breadcrumb a.current { color: var(--accent-color); max-width: none; flex: 0 1 auto; }
.breadcrumb a.current svg { fill: var(--accent-color) }
</style>

View File

@@ -28,11 +28,11 @@
<tr
:id="`file-${doc.key}`"
:class="{ file: !doc.dir, folder: doc.dir, cursor: cursor === doc }"
@click="cursor = cursor === doc ? null : doc"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
@contextmenu.prevent="contextMenu($event, doc)"
>
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
<input
type="checkbox"
tabindex="-1"
@@ -49,16 +49,10 @@
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
</template>
<template v-else>
<a
:href="doc.url"
tabindex="-1"
@contextmenu.prevent
@focus.stop="cursor = doc"
@keyup.left="router.back()"
@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>
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
{{ doc.name }}
</a>
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button>
</template>
</td>
<FileModified :doc=doc :key=nowkey />
@@ -75,7 +69,6 @@
</tr>
</tbody>
</table>
<div v-else class="empty-container">Nothing to see here</div>
</template>
<script setup lang="ts">
@@ -88,6 +81,7 @@ import { formatSize } from '@/utils'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
import type { SortOrder } from '@/utils/docsort'
import type SvgButtonVue from './SvgButton.vue'
const props = defineProps<{
path: Array<string>
@@ -95,7 +89,6 @@ const props = defineProps<{
}>()
const store = useMainStore()
const router = useRouter()
const cursor = shallowRef<Doc | null>(null)
// File rename
const editing = shallowRef<Doc | null>(null)
const rename = (doc: Doc, newName: string) => {
@@ -138,41 +131,45 @@ defineExpose({
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const order = ['', 'name', 'modified', 'size', ''][column]
if (order) store.toggleSort(order as SortOrder)
},
isCursor() {
return cursor.value !== null && editing.value === null
return store.cursor && editing.value === null
},
cursorRename() {
editing.value = cursor.value
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
},
cursorSelect() {
const doc = cursor.value
if (!doc) return
if (store.selected.has(doc.key)) {
store.selected.delete(doc.key)
const key = store.cursor
if (!key) return
if (store.selected.has(key)) {
store.selected.delete(key)
} else {
store.selected.add(doc.key)
store.selected.add(key)
}
this.cursorMove(1)
this.cursorMove(1, null)
},
cursorMove(d: number, select = false) {
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
left(ev: KeyboardEvent) { router.back() },
right(ev: KeyboardEvent) {
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
if (a) a.click()
},
cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents
if (docs.length === 0) {
cursor.value = null
store.cursor = ''
return
}
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 ? docs.indexOf(cursor.value) : docs.length
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
const moveto = increment(index, d)
cursor.value = docs[moveto] ?? null
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
@@ -202,18 +199,18 @@ const focusBreadcrumb = () => {
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (cursor.value && cursor.value !== editing.value) editing.value = null
if (editing.value) cursor.value = editing.value
if (cursor.value) {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value?.key
if (store.cursor) {
const a = document.querySelector(
`#file-${cursor.value.key} .name a`
`#file-${store.cursor} .name a`
) as HTMLAnchorElement | null
if (a) a.focus()
}
})
watchEffect(() => {
if (!props.documents.length && cursor.value) {
cursor.value = null
if (!props.documents.length && store.cursor) {
store.cursor = ''
focusBreadcrumb()
}
})
@@ -287,7 +284,7 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: MouseEvent, doc: Doc) => {
cursor.value = doc
store.cursor = doc.key
ContextMenu.showContextMenu({
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
@@ -310,29 +307,36 @@ tbody tr {
position: relative;
z-index: auto;
}
table thead input[type='checkbox'] {
table thead .selection input[type='checkbox'] {
position: inherit;
width: 1em;
height: 1em;
padding: 0.5rem 0.5em;
width: 1rem;
height: 1rem;
padding: 0;
margin: auto;
}
table tbody input[type='checkbox'] {
table tbody .selection input[type='checkbox'] {
width: 2rem;
height: 2rem;
}
table .selection {
width: 2rem;
width: 3rem;
text-align: center;
text-overflow: clip;
padding: 0;
}
table .selection input {
margin: auto;
}
table .modified {
width: 9em;
width: 10rem;
text-overflow: clip;
}
table .size {
width: 5em;
width: 7rem;
text-overflow: clip;
}
table .menu {
width: 1rem;
width: 2rem;
}
tbody td {
font-size: 1.2rem;
@@ -367,7 +371,7 @@ table td {
}
}
thead tr {
font-size: var(--header-font-size);
font-size: 0.8rem;
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
color: #000;
box-shadow: 0 0 .2rem black;
@@ -388,9 +392,11 @@ tbody tr.cursor {
padding-right: 1.5rem;
}
.sortcolumn::after {
font-size: 1rem;
content: '▸';
color: #888;
margin-left: 0.5em;
margin-left: 0.5rem;
margin-top: -.2rem;
position: absolute;
transition: all var(--transition-time) linear;
}

View File

@@ -0,0 +1,254 @@
<template>
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-for="(doc, index) in documents" :key="doc.key" :doc="doc" :index="index">
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" v-if="showFolderBreadcrumb(index)" class="folder-change"/>
</GalleryFigure>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
import { connect, controlUrl } from '@/repositories/WS'
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>
documents: Doc[]
}>()
const store = useMainStore()
const router = useRouter()
// File rename
const editing = shallowRef<Doc | null>(null)
const rename = (doc: Doc, newName: string) => {
const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log('Rename succeeded', msg)
}
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'rename',
path: `${doc.loc}/${oldName}`,
to: newName
})
)
}
doc.name = newName // We should get an update from watch but this is quicker
}
const gallery = ref<HTMLElement>()
const columns = computed(() => {
if (!gallery.value) return 1
return getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
})
defineExpose({
newFolder() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New Folder',
dir: true,
mtime: now,
size: 0,
})
},
toggleSelectAll() {
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const order = ['', 'name', 'modified', 'size', ''][column]
if (order) store.toggleSort(order as SortOrder)
},
isCursor() {
return store.cursor && editing.value === null
},
cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
},
cursorSelect() {
const key = store.cursor
if (!key) return
if (store.selected.has(key)) {
store.selected.delete(key)
} else {
store.selected.add(key)
}
this.cursorMove(1, null)
},
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) },
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) },
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
right(ev: KeyboardEvent) { this.cursorMove(1, ev) },
cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
return
}
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 =
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
let moveto
if (index === N) moveto = d > 0 ? 0 : N - 1
else {
moveto = increment(index, d)
// Wrapping either end, just land outside the list
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
}
console.log("Gallery cursorMove", d, index, moveto, moveto - index)
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
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)
}
}
// @ts-ignore
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
if (scrolltr)
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
if (moveto === N) focusBreadcrumb()
}
})
const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus()
}
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value.key
if (store.cursor) {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) }
}
})
watchEffect(() => {
if (!props.documents.length && store.cursor) {
store.cursor = ''
focusBreadcrumb()
}
})
let nowkey = ref(0)
let modifiedTimer: any = null
const updateModified = () => {
nowkey.value = Math.floor(Date.now() / 1000)
}
onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) })
onUnmounted(() => { clearInterval(modifiedTimer) })
const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, {
open() {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${doc.loc}/${name}`
})
)
},
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Mkdir failed', msg.error.message, msg.error)
editing.value = null
} else {
console.log('mkdir', msg)
router.push(doc.urlrouter)
}
}
})
// We should get an update from watch but this is quicker
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
}
const selectionIndeterminate = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
!allSelected.value
)
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
set: (value: boolean) => {}
})
const allSelected = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.every((doc: Doc) => store.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)
} else {
store.selected.delete(doc.key)
}
}
}
})
const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
ContextMenu.showContextMenu({
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
],
})
}
</script>
<style scoped>
.gallery {
padding: 1rem;
width: 100%;
display: grid;
gap: .5rem;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
grid-auto-rows: 15rem;
}
.breadcrumb {
position: absolute;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
@contextmenu.stop
@focus.stop="store.cursor = doc.key"
@click="ev => {
if (m!.play()) ev.preventDefault()
store.cursor = doc.key
}"
>
<figure>
<slot></slot>
<MediaPreview ref=m :doc="doc" :tabindex=-1 />
<caption>
<label>
<SelectBox :doc=doc />
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span>
</label>
</caption>
</figure>
</a>
</template>
<script setup lang=ts>
import { ref } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import MediaPreview from '@/components/MediaPreview.vue'
const store = useMainStore()
const props = defineProps<{
doc: Doc
index: number
}>()
const m = ref<typeof MediaPreview | null>(null)
</script>
<style scoped>
.gallery figure {
height: 15rem;
position: relative;
border-radius: .5rem;
overflow: hidden;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
overflow: hidden;
}
figure caption {
font-weight: 600;
color: var(--text-color);
text-shadow: 0 0 .2rem #000, 0 0 1rem #000;
}
.cursor caption {
background: var(--accent-color);
}
caption {
position: absolute;
overflow: hidden;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
caption label {
width: 100%;
display: flex;
align-items: center;
}
label span {
flex: 1 1;
margin-right: 2rem;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
label input[type='checkbox'] {
width: 2rem;
height: 2rem;
opacity: 0;
flex-shrink: 0;
}
label input[type='checkbox']:checked {
opacity: 1;
}
a {
text-decoration: none;
}
</style>

View File

@@ -1,31 +1,30 @@
<template>
<nav class="headermain">
<div class="buttons">
<template v-if="store.error">
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
data-tooltip="New folder"
@click="() => store.fileExplorer!.newFolder()"
<nav class="headermain buttons">
<template v-if="store.error">
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
data-tooltip="New folder"
@click="() => store.fileExplorer!.newFolder()"
/>
<slot></slot>
<div class="spacer smallgap"></div>
<template v-if="showSearchInput">
<input
ref="search"
type="search"
:value="query"
@input="updateSearch"
placeholder="Find files"
class="margin-input"
/>
<slot></slot>
<div class="spacer smallgap"></div>
<template v-if="showSearchInput">
<input
ref="search"
type="search"
:value="query"
@input="updateSearch"
placeholder="Search words"
class="margin-input"
/>
</template>
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
<SvgButton name="cog" @click="settingsMenu" />
</div>
</template>
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" />
<SvgButton name="cog" @click="settingsMenu" />
</nav>
</template>
@@ -93,24 +92,18 @@ defineExpose({
<style scoped>
.buttons {
flex: 1000 0 auto;
padding: 0;
display: flex;
align-items: center;
height: 3.5em;
z-index: 10;
}
.buttons > * {
flex-shrink: 1;
}
input[type='search'] {
background: var(--input-background);
color: var(--input-color);
border: 0;
border-radius: 0.1em;
padding: 0.5em;
outline: none;
font-size: 1.5em;
max-width: 30vw;
max-width: 15ch;
}
</style>
@/stores/main

View File

@@ -150,6 +150,6 @@ const download = async () => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
}
</style>
@/stores/main

View File

@@ -0,0 +1,100 @@
<template>
<img v-if=doc.img :src="preview() ? doc.previewurl : doc.url" alt="">
<span v-else-if=doc.dir class="folder icon"></span>
<video ref=vid v-else-if=video() :src=doc.url controls preload=none @click.prevent>📄</video>
<audio ref=aud v-else-if=audio() :src=doc.url controls preload=metadata @click.stop>📄</audio>
<span v-else-if=archive() class="archive icon"></span>
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
</template>
<script setup lang=ts>
import { compile, computed, ref } from 'vue'
import type { Doc } from '@/repositories/Document'
const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null)
const media = computed(() => aud.value || vid.value)
const props = defineProps<{
doc: Doc
}>()
defineExpose({
play() {
if (media.value) {
if (media.value.paused) media.value.play()
else media.value.pause()
return true
}
return false
},
})
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
const preview = () => (
props.doc.size > 500000 &&
['png', 'bmp', 'ico', 'webp', 'avif', 'jpg', 'jpeg'].includes(props.doc.ext)
)
</script>
<style scoped>
img, embed, .icon {
font-size: 10em;
border-radius: .5rem;
overflow: hidden;
text-align: center;
object-fit: cover;
object-position: center;
min-width: 50%;
height: 100%;
}
audio, video {
height: 100%;
min-width: 50%;
max-width: 100%;
padding-bottom: 2rem;
margin: auto;
}
.folder::before {
content: '📁';
}
.folder:hover::before, .cursor .folder::before {
content: '📂';
}
.archive::before {
content: '📦';
}
.file::before {
content: '📄';
}
.ext-img::before {
content: '💿';
}
.ext-exe::before, .ext-msi::before, .ext-dmg::before, .ext-pkg::before {
content: '⚙️';
}
.ext-torrent::before {
content: '🏴‍☠️';
}
.icon {
filter: brightness(0.9);
}
figure.cursor .icon {
filter: brightness(1);
}
img::before, video::before {
/* broken image */
background: #888;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
text-align: center;
text-shadow: 0 0 .5rem #000;
filter: grayscale(1);
content: '❌';
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<input type=checkbox tabindex=-1 :checked="store.selected.has(doc.key)"
@change="ev => {
if ((ev.target as HTMLInputElement).checked) {
store.selected.add(doc.key)
} else {
store.selected.delete(doc.key)
}
}"
>
</template>
<script setup lang=ts>
import { useMainStore } from '@/stores/main'
import type { Doc } from '@/repositories/Document'
const props = defineProps<{
doc: Doc
}>()
const store = useMainStore()
</script>

View File

@@ -6,7 +6,7 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineProps } from 'vue'
import { defineAsyncComponent } from 'vue'
const props = defineProps<{
name: string

View File

@@ -36,6 +36,17 @@ export class Doc {
get urlrouter(): string {
return this.url.replace(/^\/#/, '')
}
get img(): boolean {
const ext = this.name.split('.').pop()?.toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'].includes(ext || '')
}
get previewurl(): string {
return this.url.replace(/^\/files/, '/preview')
}
get ext(): string {
const ext = this.name.split('.').pop()
return ext ? ext.toLowerCase() : ''
}
}
export type errorEvent = {
error: {

View File

@@ -23,8 +23,10 @@ export const useMainStore = defineStore({
fileExplorer: null as any,
error: '' as string,
connected: false,
cursor: '' as string,
server: {} as Record<string, any>,
prefs: {
gallery: false,
sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder,
},
@@ -77,6 +79,13 @@ export const useMainStore = defineStore({
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name
},
sort(name: SortOrder | '') {
if (this.query) this.prefs.sortFiltered = name
else this.prefs.sortListing = name
},
focusBreadcrumb() {
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
}
},
getters: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },

View File

@@ -1,19 +1,40 @@
<template>
<FileExplorer
<div v-if="!props.path || documents.length === 0" class="empty-container">
<component :is="cog" class="cog"/>
<p v-if="!store.connected">No Connection</p>
<p v-else-if="store.document.length === 0">Waiting for File List</p>
<p v-else-if="store.query">No matches!</p>
<p v-else-if="!store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === props.path.join('/'))">Folder not found.</p>
<p v-else>Empty folder</p>
</div>
<Gallery
v-else-if="store.prefs.gallery"
ref="fileExplorer"
:key="Router.currentRoute.value.path"
:key="`gallery-${Router.currentRoute.value.path}`"
:path="props.path"
:documents="documents"
v-if="props.path"
/>
<FileExplorer
v-else
ref="fileExplorer"
:key="`explorer-${Router.currentRoute.value.path}`"
:path="props.path"
:documents="documents"
/>
<div v-if="!store.prefs.gallery && documents.some(doc => doc.img)" class="suggest-gallery">
<p>Media files found. Would you like a gallery view?</p>
<SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }">Gallery</SvgButton>
</div>
</template>
<script setup lang="ts">
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';
import { needleFormat, localeIncludes, collator } from '@/utils'
import { sorted } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue'
import cog from '@/assets/svg/cog.svg'
const store = useMainStore()
const fileExplorer = ref()
@@ -64,3 +85,39 @@ watchEffect(() => {
store.query = props.query
})
</script>
<style scoped>
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
text-shadow: 0 0 1rem #000, 0 0 2rem #000;
color: var(--accent-color);
}
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(359deg); }
}
.suggest-gallery p {
font-size: 2rem;
color: var(--accent-color);
}
.suggest-gallery {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
svg.cog {
width: 10rem;
height: 10rem;
margin: 0 auto;
animation: rotate 10s linear infinite;
filter: drop-shadow(0 0 1rem black);
fill: var(--primary-color);
}
</style>

View File

@@ -45,6 +45,7 @@ export default defineConfig({
"/login": dev_backend,
"/logout": dev_backend,
"/zip": dev_backend,
"/preview": dev_backend,
}
},
build: {

View File

@@ -22,6 +22,7 @@ dependencies = [
"msgspec",
"natsort",
"pathvalidate",
"pillow",
"pyjwt",
"sanic",
"stream-zip",