11 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
18 changed files with 194 additions and 116 deletions

View File

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

@@ -62,23 +62,14 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
event.key === 'ArrowRight' || event.key === 'ArrowRight' ||
(c && event.code === 'Space') (c && event.code === 'Space')
) { ) {
event.preventDefault() if (!input) event.preventDefault()
} }
return return
} }
//console.log("key pressed", event) //console.log("key pressed", event)
// For up/down implement custom fast repeat /// Long if-else machina for all keys we handle here
let stride = 1 let arrow = ''
if (store.gallery) { if (event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
const grid = document.querySelector('.gallery') as HTMLElement
stride = getComputedStyle(grid).gridTemplateColumns.split(' ').length
}
else if (event.altKey) stride *= 10
// Long if-else machina for all keys we handle here
if (event.key === 'ArrowUp') vert = stride * (keyup ? 0 : -1)
else if (event.key === 'ArrowDown') vert = stride * (keyup ? 0 : 1)
else if (event.key === 'ArrowLeft') vert = keyup ? 0 : -1
else if (event.key === 'ArrowRight') vert = keyup ? 0 : 1
// Find: process on keydown so that we can bypass the built-in search hotkey // Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) { else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
headerMain.value!.toggleSearchInput() headerMain.value!.toggleSearchInput()
@@ -87,19 +78,24 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if (keyup && !input && event.key === '/') { else if (keyup && !input && event.key === '/') {
headerMain.value!.toggleSearchInput() headerMain.value!.toggleSearchInput()
} }
// Globally close search on Escape // Globally close search, clear errors on Escape
else if (keyup && event.key === 'Escape') { else if (keyup && event.key === 'Escape') {
store.error = ''
headerMain.value!.closeSearch(event) 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)) { else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
fileExplorer.toggleSelectAll() fileExplorer.toggleSelectAll()
} }
// G toggles Gallery // G toggles Gallery
else if (keyup && event.key === 'g') { else if (!input && keyup && event.key === 'g') {
store.gallery = !store.gallery store.prefs.gallery = !store.prefs.gallery
} }
// Keys 1-3 to sort columns // Keys Backquote-1-2-3 to sort columns
else if ( else if (
!input && !input &&
keyup && keyup &&
@@ -115,28 +111,26 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if (c && event.code === 'Space') { else if (c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey) if (keyup && !event.altKey && !event.ctrlKey)
fileExplorer.cursorSelect() 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 // Initial move, then t0 delay until repeats at tr intervals
const select = event.shiftKey const t0 = 200, tr = event.altKey ? 20 : 100
fileExplorer.cursorMove(vert, select) f()
const t0 = 200, timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr)
tr = 30
timer = setTimeout(
() =>
(timer = setInterval(() => {
fileExplorer.cursorMove(vert, select)
}, tr)),
t0 - tr
)
} }
} }
onMounted(() => { onMounted(() => {

View File

@@ -92,7 +92,7 @@
nav, nav,
.menu, .menu,
.rename-button { .rename-button {
display: none; display: none !important;
} }
.breadcrumb > a { .breadcrumb > a {
color: black !important; color: black !important;
@@ -113,6 +113,7 @@
padding-bottom: 0 !important; padding-bottom: 0 !important;
} }
thead tr { thead tr {
font-size: 1rem !important;
position: static !important; position: static !important;
background: none !important; background: none !important;
border-bottom: 1pt solid black !important; border-bottom: 1pt solid black !important;

View File

@@ -2,8 +2,8 @@
<nav <nav
class="breadcrumb" class="breadcrumb"
aria-label="Breadcrumb" aria-label="Breadcrumb"
@keyup.left.stop="move(-1)" @keydown.left.stop="move(-1)"
@keyup.right.stop="move(1)" @keydown.right.stop="move(1)"
@keyup.enter="move(0)" @keyup.enter="move(0)"
@focus=focusCurrent @focus=focusCurrent
tabindex=0 tabindex=0

View File

@@ -49,15 +49,9 @@
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" /> <FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
</template> </template>
<template v-else> <template v-else>
<a <a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
:href="doc.url" {{ doc.name }}
tabindex="-1" </a>
@contextmenu.prevent
@focus.stop="store.cursor = doc.key"
@keyup.left="router.back()"
@keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }"
>{{ doc.name }}</a
>
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button> <button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button>
</template> </template>
</td> </td>
@@ -137,15 +131,11 @@ defineExpose({
console.log('Select') console.log('Select')
allSelected.value = !allSelected.value allSelected.value = !allSelected.value
}, },
toggleSortColumn(column: number) {
const order = ['', 'name', 'modified', 'size', ''][column]
if (order) store.toggleSort(order as SortOrder)
},
isCursor() { isCursor() {
return store.cursor && editing.value === null return store.cursor && editing.value === null
}, },
cursorRename() { cursorRename() {
editing.value = store.cursor editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
}, },
cursorSelect() { cursorSelect() {
const key = store.cursor const key = store.cursor
@@ -155,9 +145,17 @@ defineExpose({
} else { } else {
store.selected.add(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) // Move cursor up or down (keyboard navigation)
const docs = props.documents const docs = props.documents
if (docs.length === 0) { if (docs.length === 0) {
@@ -168,7 +166,7 @@ defineExpose({
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = const index =
store.cursor ? docs.find(doc => doc.key === store.cursor) : docs.length store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
const moveto = increment(index, d) const moveto = increment(index, d)
store.cursor = docs[moveto]?.key ?? '' store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : '' const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
@@ -309,29 +307,36 @@ tbody tr {
position: relative; position: relative;
z-index: auto; z-index: auto;
} }
table thead input[type='checkbox'] { table thead .selection input[type='checkbox'] {
position: inherit; position: inherit;
width: 1em; width: 1rem;
height: 1em; height: 1rem;
padding: 0.5rem 0.5em; padding: 0;
margin: auto;
} }
table tbody input[type='checkbox'] { table tbody .selection input[type='checkbox'] {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
} }
table .selection { table .selection {
width: 2rem; width: 3rem;
text-align: center; text-align: center;
text-overflow: clip; text-overflow: clip;
padding: 0;
}
table .selection input {
margin: auto;
} }
table .modified { table .modified {
width: 9em; width: 10rem;
text-overflow: clip;
} }
table .size { table .size {
width: 5em; width: 7rem;
text-overflow: clip;
} }
table .menu { table .menu {
width: 1rem; width: 2rem;
} }
tbody td { tbody td {
font-size: 1.2rem; font-size: 1.2rem;
@@ -366,7 +371,7 @@ table td {
} }
} }
thead tr { thead tr {
font-size: var(--header-font-size); font-size: 0.8rem;
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd); background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
color: #000; color: #000;
box-shadow: 0 0 .2rem black; box-shadow: 0 0 .2rem black;
@@ -387,9 +392,11 @@ tbody tr.cursor {
padding-right: 1.5rem; padding-right: 1.5rem;
} }
.sortcolumn::after { .sortcolumn::after {
font-size: 1rem;
content: '▸'; content: '▸';
color: #888; color: #888;
margin-left: 0.5em; margin-left: 0.5rem;
margin-top: -.2rem;
position: absolute; position: absolute;
transition: all var(--transition-time) linear; transition: all var(--transition-time) linear;
} }

View File

@@ -1,15 +1,13 @@
<template> <template>
<div v-if="props.documents.length || editing" class="gallery"> <div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<template v-for="(doc, index) in documents" :key="doc.key"> <GalleryFigure v-for="(doc, index) in documents" :key="doc.key" :doc="doc" :index="index">
<GalleryFigure :doc="doc" :index="index"> <BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" v-if="showFolderBreadcrumb(index)" class="folder-change"/>
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" v-if="showFolderBreadcrumb(index)" class="folder-change"/> </GalleryFigure>
</GalleryFigure>
</template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue' import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
@@ -51,6 +49,11 @@ const rename = (doc: Doc, newName: string) => {
} }
doc.name = newName // We should get an update from watch but this is quicker 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({ defineExpose({
newFolder() { newFolder() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
@@ -85,9 +88,14 @@ defineExpose({
} else { } else {
store.selected.add(key) store.selected.add(key)
} }
this.cursorMove(1) this.cursorMove(1, null)
}, },
cursorMove(d: number, select = false) { 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) // Move cursor up or down (keyboard navigation)
const docs = props.documents const docs = props.documents
if (docs.length === 0) { if (docs.length === 0) {
@@ -98,8 +106,15 @@ defineExpose({
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = const index =
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
const moveto = increment(index, d) 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 ?? '' store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : '' const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
if (select) { if (select) {
@@ -134,10 +149,8 @@ watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value.key if (editing.value) store.cursor = editing.value.key
if (store.cursor) { if (store.cursor) {
const a = document.querySelector( const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
`#file-${store.cursor} a` if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) }
) as HTMLAnchorElement | null
if (a) a.focus()
} }
}) })
watchEffect(() => { watchEffect(() => {

View File

@@ -1,19 +1,16 @@
<template> <template>
<a <a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
:id="`file-${doc.key}`"
:href="doc.url"
tabindex=0
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }" :class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
@contextmenu.prevent @contextmenu.stop
@focus.stop="store.cursor = doc.key" @focus.stop="store.cursor = doc.key"
@click="ev => { @click="ev => {
store.cursor = store.cursor === doc.key ? '' : doc.key if (m!.play()) ev.preventDefault()
if (media) { media.play(); ev.preventDefault() } store.cursor = doc.key
}" }"
> >
<figure> <figure>
<slot></slot> <slot></slot>
<MediaPreview ref=media :doc="doc" /> <MediaPreview ref=m :doc="doc" :tabindex=-1 />
<caption> <caption>
<label> <label>
<SelectBox :doc=doc /> <SelectBox :doc=doc />
@@ -25,7 +22,7 @@
</template> </template>
<script setup lang=ts> <script setup lang=ts>
import { defineProps, ref } from 'vue' import { ref } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import MediaPreview from '@/components/MediaPreview.vue' import MediaPreview from '@/components/MediaPreview.vue'
@@ -35,7 +32,7 @@ const props = defineProps<{
doc: Doc doc: Doc
index: number index: number
}>() }>()
const media = ref<typeof MediaPreview | null>(null) const m = ref<typeof MediaPreview | null>(null)
</script> </script>
<style scoped> <style scoped>

View File

@@ -18,12 +18,12 @@
type="search" type="search"
:value="query" :value="query"
@input="updateSearch" @input="updateSearch"
placeholder="Search words" placeholder="Find files"
class="margin-input" class="margin-input"
/> />
</template> </template>
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" /> <SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
<SvgButton name="eye" @click="store.gallery = !store.gallery" /> <SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" />
<SvgButton name="cog" @click="settingsMenu" /> <SvgButton name="cog" @click="settingsMenu" />
</nav> </nav>
</template> </template>

View File

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

View File

@@ -1,19 +1,19 @@
<template> <template>
<img v-if=doc.img :src=doc.url alt=""> <img v-if=doc.img :src="preview() ? doc.previewurl : doc.url" alt="">
<span v-else-if=doc.dir class="folder icon"></span> <span v-else-if=doc.dir class="folder icon"></span>
<video v-else-if=video() ref=media :src=doc.url controls preload=metadata @click.prevent>📄</video> <video ref=vid v-else-if=video() :src=doc.url controls preload=none @click.prevent>📄</video>
<audio v-else-if=audio() ref=media :src=doc.url controls preload=metadata @click.stop>📄</audio> <audio ref=aud v-else-if=audio() :src=doc.url controls preload=metadata @click.stop>📄</audio>
<embed v-else-if=embed() :src=doc.url type=text/plain @click.stop @scroll.prevent>
<span v-else-if=archive() class="archive icon"></span> <span v-else-if=archive() class="archive icon"></span>
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span> <span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
</template> </template>
<script setup lang=ts> <script setup lang=ts>
import { defineProps, ref } from 'vue' import { compile, computed, ref } from 'vue'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'
const media = ref<HTMLAudioElement | HTMLVideoElement | null>(null) const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null)
const media = computed(() => aud.value || vid.value)
const props = defineProps<{ const props = defineProps<{
doc: Doc doc: Doc
}>() }>()
@@ -21,16 +21,22 @@ const props = defineProps<{
defineExpose({ defineExpose({
play() { play() {
if (media.value) { if (media.value) {
media.value.play() 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 video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext) const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
const embed = () => ['txt', 'py', 'html', 'css', 'js', 'json', 'xml', 'csv', 'tsv'].includes(props.doc.ext)
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].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> </script>
<style scoped> <style scoped>

View File

@@ -11,7 +11,6 @@
</template> </template>
<script setup lang=ts> <script setup lang=ts>
import { defineProps } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,13 @@
<div v-if="!props.path || documents.length === 0" class="empty-container"> <div v-if="!props.path || documents.length === 0" class="empty-container">
<component :is="cog" class="cog"/> <component :is="cog" class="cog"/>
<p v-if="!store.connected">No Connection</p> <p v-if="!store.connected">No Connection</p>
<p v-else-if="store.document.length === 0">Waiting for Files</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.query">No matches!</p>
<p v-else-if="!store.document.find(doc => doc.loc.length + 1 === props.path.length && [...doc.loc, doc.name].join('/') === props.path.join('/'))">Folder not found.</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> <p v-else>Empty folder</p>
</div> </div>
<Gallery <Gallery
v-else-if="store.gallery" v-else-if="store.prefs.gallery"
ref="fileExplorer" ref="fileExplorer"
:key="`gallery-${Router.currentRoute.value.path}`" :key="`gallery-${Router.currentRoute.value.path}`"
:path="props.path" :path="props.path"
@@ -21,9 +21,9 @@
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
<div v-if="!store.gallery && documents.some(doc => doc.img)" class="suggest-gallery"> <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> <p>Media files found. Would you like a gallery view?</p>
<SvgButton name="eye" taborder=0 @click="() => { store.gallery = true }">Gallery</SvgButton> <SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }">Gallery</SvgButton>
</div> </div>
</template> </template>

View File

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

View File

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