Compare commits

...
11 Commits
Author SHA1 Message Date
LeoVasanko 146497d731 Fix display of vertical images and videos such they fit in the gallery item. 2026-01-22 02:35:24 +00:00
LeoVasanko 442816a0ae Fix video preview images that were displayed sideways for portrait video. The least bad approach loses HDR but shows in correct orientation. For 0 and 180 degrees we preserve HDR too. 2026-01-22 02:20:39 +00:00
LeoVasanko d32afa6016 Display play button on video previews to indicate it is a video that can be played. 2026-01-22 01:43:50 +00:00
LeoVasanko fa60c962c4 Attempt a better mobile layout, to fix sizing issues with Brave Android. 2026-01-22 00:57:30 +00:00
LeoVasanko e55e11b399 Fix flickering tooltip when hovering modified in FileExplorer. Use the new, improved tooltip also in gallery to show full name, modified and size of an item. 2026-01-22 00:49:50 +00:00
LeoVasanko b6c21152e7 Fix modified tooltip (exact timestamp) flickering on/off because of timestamp updates every second. Made the tooltip follow mouse cursor. 2026-01-22 00:39:26 +00:00
LeoVasanko f354fc5c71 Less aggressive automatic gallery mode switching, only when folder changes. Fixes issues with focus being lost from search. 2026-01-22 00:32:18 +00:00
LeoVasanko 5bda809921 Cleaner folder headers in gallery mode search results. Multi folder results are always grouped by folder (FileExplorer and Gallery), but still otherwise respecting the chosen sort order. Overall this produces a much cleaner layout. 2026-01-22 00:23:23 +00:00
LeoVasanko 2cc92cd786 Deprecation, remove unused import. 2026-01-22 00:16:59 +00:00
LeoVasanko ba6380e71e Add a script to run devserver. Migrate to build and JS utils provided by fastapi-vue. Frontend directory renamed to frontend-build. Update Sanic, deprecations. 2026-01-21 23:32:04 +00:00
LeoVasanko 0d853032bf Cleaner handling when preview generation fails. Using original file as fallback. 2026-01-21 23:29:51 +00:00
22 changed files with 681 additions and 135 deletions
+1 -1
View File
@@ -4,5 +4,5 @@
__pycache__/ __pycache__/
*.egg-info/ *.egg-info/
/cista/_version.py /cista/_version.py
/cista/wwwroot/* /cista/frontend-build/
/dist /dist
+2 -2
View File
@@ -15,12 +15,12 @@ fileserver = FileServer()
@bp.before_server_start @bp.before_server_start
async def start_fileserver(app, _): async def start_fileserver(app):
await fileserver.start() await fileserver.start()
@bp.after_server_stop @bp.after_server_stop
async def stop_fileserver(app, _): async def stop_fileserver(app):
await fileserver.stop() await fileserver.stop()
+6 -6
View File
@@ -36,19 +36,19 @@ setproctitle("cista-main")
@app.before_server_start @app.before_server_start
async def main_start(app, loop): async def main_start(app):
config.load_config() config.load_config()
setproctitle(f"cista {config.config.path.name}") setproctitle(f"cista {config.config.path.name}")
workers = max(2, min(8, cpu_count())) workers = max(2, min(8, cpu_count()))
app.ctx.threadexec = ThreadPoolExecutor( app.ctx.threadexec = ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="cista-ioworker" max_workers=workers, thread_name_prefix="cista-ioworker"
) )
watching.start(app, loop) watching.start(app)
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers) # Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
@app.before_server_stop @app.before_server_stop
async def main_stop(app, loop): async def main_stop(app):
quit.set() quit.set()
watching.stop(app) watching.stop(app)
app.ctx.threadexec.shutdown() app.ctx.threadexec.shutdown()
@@ -75,7 +75,7 @@ async def use_session(req):
@app.before_server_start @app.before_server_start
def http_fileserver(app, _): def http_fileserver(app):
bp = Blueprint("fileserver") bp = Blueprint("fileserver")
bp.on_request(auth.verify) bp.on_request(auth.verify)
bp.static( bp.static(
@@ -93,9 +93,9 @@ www = {}
def _load_wwwroot(www): def _load_wwwroot(www):
wwwnew = {} wwwnew = {}
base = Path(__file__).with_name("wwwroot") base = Path(__file__).with_name("frontend-build")
paths = [PurePath()] paths = [PurePath()]
zstd = ZstdCompressor(level=10) zstd = ZstdCompressor(level=18)
while paths: while paths:
path = paths.pop(0) path = paths.pop(0)
current = base / path current = base / path
+41 -18
View File
@@ -13,7 +13,7 @@ import fitz # PyMuPDF
import numpy as np import numpy as np
import pillow_heif import pillow_heif
from PIL import Image from PIL import Image
from sanic import Blueprint, empty, raw from sanic import Blueprint, empty, raw, redirect
from sanic.exceptions import NotFound from sanic.exceptions import NotFound
from sanic.log import logger from sanic.log import logger
@@ -43,12 +43,12 @@ async def preview(req, path):
maxzoom = float(req.args.get("zoom", 2.0)) maxzoom = float(req.args.get("zoom", 2.0))
quality = int(req.args.get("q", 60)) quality = int(req.args.get("q", 60))
rel = PurePosixPath(sanitize(unquote(path))) rel = PurePosixPath(sanitize(unquote(path)))
path = config.config.path / rel filepath = config.config.path / rel
stat = path.lstat() stat = filepath.lstat()
etag = config.derived_secret( etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex() ).hex()
savename = PurePosixPath(path.name).with_suffix(".avif") savename = PurePosixPath(filepath.name).with_suffix(".avif")
headers = { headers = {
"etag": etag, "etag": etag,
"last-modified": format_date_time(stat.st_mtime), "last-modified": format_date_time(stat.st_mtime),
@@ -61,22 +61,30 @@ async def preview(req, path):
# The client has it cached, respond 304 Not Modified # The client has it cached, respond 304 Not Modified
return empty(304, headers=headers) return empty(304, headers=headers)
if not path.is_file(): if not filepath.is_file():
raise NotFound("File not found") raise NotFound("File not found")
img = await asyncio.get_event_loop().run_in_executor( img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
) )
if not img:
# Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303)
return raw(img, headers=headers) return raw(img, headers=headers)
def dispatch(path, quality, maxsize, maxzoom): def dispatch(path, quality, maxsize, maxzoom):
try:
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"): if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom) return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
type, _ = mimetypes.guess_type(path.name) type, _ = mimetypes.guess_type(path.name)
if type and type.startswith("video/"): if type and type.startswith("video/"):
return process_video(path, quality=quality, maxsize=maxsize) return process_video(path, quality=quality, maxsize=maxsize)
return process_image(path, quality=quality, maxsize=maxsize) return process_image(path, quality=quality, maxsize=maxsize)
except ValueError as e:
logger.warning(f"Cannot generate preview for {path.name}: {e}")
except Exception as e:
logger.exception(f"Error generating preview for {path.name}: {e}")
def process_image(path, *, maxsize, quality): def process_image(path, *, maxsize, quality):
@@ -121,7 +129,7 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
w, h = page.rect[2:4] w, h = page.rect[2:4]
zoom = min(maxsize / w, maxsize / h, maxzoom) zoom = min(maxsize / w, maxsize / h, maxzoom)
mat = fitz.Matrix(zoom, zoom) mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat) # type: ignore[attr-defined] pix = page.get_pixmap(matrix=mat)
t_load_end = perf_counter() t_load_end = perf_counter()
t_save_start = perf_counter() t_save_start = perf_counter()
@@ -166,22 +174,27 @@ def process_video(path, *, maxsize, quality):
new_height = int(frame.height * scale_factor) new_height = int(frame.height * scale_factor)
frame = frame.reformat(width=new_width, height=new_height) frame = frame.reformat(width=new_width, height=new_height)
# Simple rotation detection and logging # Apply EXIF rotation if present
if frame.rotation: if frame.rotation:
# frame.rotation indicates clockwise rotation needed to display correctly
# np.rot90 rotates counter-clockwise, so we negate k
k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
if k == 2:
# 180° rotation can be done in YUV420p, preserving HDR
try: try:
fplanes = frame.to_ndarray() fplanes = frame.to_ndarray()
# Split into Y, U, V planes of proper dimensions # Split into Y, U, V planes of proper dimensions
planes = [ planes = [
fplanes[: frame.height], fplanes[: frame.height],
fplanes[frame.height : frame.height + frame.height // 4].reshape( fplanes[
frame.height // 2, frame.width // 2 frame.height : frame.height + frame.height // 4
), ].reshape(frame.height // 2, frame.width // 2),
fplanes[frame.height + frame.height // 4 :].reshape( fplanes[frame.height + frame.height // 4 :].reshape(
frame.height // 2, frame.width // 2 frame.height // 2, frame.width // 2
), ),
] ]
# Rotate # Rotate each plane by 180°
planes = [np.rot90(p, frame.rotation // 90) for p in planes] planes = [np.rot90(p, 2) for p in planes]
# Restore PyAV format # Restore PyAV format
planes = np.hstack([p.flat for p in planes]).reshape( planes = np.hstack([p.flat for p in planes]).reshape(
-1, planes[0].shape[1] -1, planes[0].shape[1]
@@ -189,12 +202,21 @@ def process_video(path, *, maxsize, quality):
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name) frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
del planes, fplanes del planes, fplanes
except Exception as e: except Exception as e:
if "not yet supported" in str(e): logger.exception(f"Error rotating video frame by 180°: {e}")
logger.warning( elif k in (1, 3):
f"Not rotating {path.name} preview image by {frame.rotation}°:\n PyAV: {e}" # 90° or 270° rotation requires RGB conversion (loses HDR)
try:
rgb = frame.to_ndarray(format="rgb24")
rgb = np.rot90(rgb, k)
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
frame = frame.reformat(
format="yuv420p"
) # Convert back for encoding
del rgb
except Exception as e:
logger.exception(
f"Error rotating video frame by {frame.rotation}°: {e}"
) )
else:
logger.exception(f"Error rotating video frame: {e}")
t_load_end = perf_counter() t_load_end = perf_counter()
t_save_start = perf_counter() t_save_start = perf_counter()
@@ -211,6 +233,7 @@ def process_video(path, *, maxsize, quality):
assert isinstance(ostream, av.VideoStream) assert isinstance(ostream, av.VideoStream)
ostream.width = frame.width ostream.width = frame.width
ostream.height = frame.height ostream.height = frame.height
ostream.pix_fmt = frame.format.name
icc = istream.codec_context icc = istream.codec_context
occ = ostream.codec_context occ = ostream.codec_context
+2 -2
View File
@@ -440,14 +440,14 @@ def watcher_poll(loop):
quit.wait(0.1 + 8 * dur) quit.wait(0.1 + 8 * dur)
def start(app, loop): def start(app):
global rootpath global rootpath
config.load_config() config.load_config()
rootpath = config.config.path rootpath = config.config.path
use_inotify = sys.platform == "linux" use_inotify = sys.platform == "linux"
app.ctx.watcher = threading.Thread( app.ctx.watcher = threading.Thread(
target=watcher_inotify if use_inotify else watcher_poll, target=watcher_inotify if use_inotify else watcher_poll,
args=[loop], args=[app.loop],
# Descriptive name for system monitoring # Descriptive name for system monitoring
name=f"cista-watcher {rootpath}", name=f"cista-watcher {rootpath}",
) )
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang=en> <html lang=en>
<meta charset=UTF-8> <meta charset=UTF-8>
<title>Cista Storage</title> <title>Cista Storage</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<link rel="icon" href="/src/assets/logo.svg"> <link rel="icon" href="/src/assets/logo.svg">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+10 -4
View File
@@ -110,6 +110,7 @@
margin: 0 .5rem 0 1rem !important; margin: 0 .5rem 0 1rem !important;
} }
body#app { body#app {
position: static !important;
height: auto !important; height: auto !important;
} }
main { main {
@@ -165,6 +166,11 @@ body {
font-family: 'Roboto'; font-family: 'Roboto';
color: var(--primary-color); color: var(--primary-color);
margin: 0; margin: 0;
/* Prevent any scrolling on body */
overflow: hidden;
/* Fallback for older browsers */
height: 100vh;
height: 100dvh;
} }
tbody .size, tbody .size,
tbody .modified { tbody .modified {
@@ -214,12 +220,14 @@ table {
gap: 0; gap: 0;
} }
body#app { body#app {
height: 100vh; position: fixed;
inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
main { main {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; /* Allow flex child to shrink below content size */
padding-bottom: 3em; /* convenience space on the bottom */ padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll; overflow-y: scroll;
text-align: center; text-align: center;
@@ -237,6 +245,7 @@ header nav.headermain {
z-index: 101; z-index: 101;
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
pointer-events: none;
font-size: 1rem; font-size: 1rem;
text-align: center; text-align: center;
padding: .5rem 1rem; padding: .5rem 1rem;
@@ -248,9 +257,6 @@ header nav.headermain {
white-space: pre; white-space: pre;
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards; animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
} }
.modified [data-tooltip]:hover:after {
transform: translate(calc(1rem + 1ex + -100%), calc(-1.5rem + 100%));
}
@keyframes appearbriefly { @keyframes appearbriefly {
from { from {
opacity: 0; opacity: 0;
+86
View File
@@ -0,0 +1,86 @@
<template>
<Teleport to="body">
<div v-if="visible" class="cursor-tooltip" :style="tooltipStyle">
<slot></slot>
</div>
</Teleport>
</template>
<script lang="ts">
// Global activation state - shared across all instances
let globalActive = false
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
</script>
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{
text: string
delay?: number
}>()
const visible = ref(false)
const mouseX = ref(0)
const mouseY = ref(0)
let hoverTimer: ReturnType<typeof setTimeout> | null = null
const tooltipStyle = computed(() => ({
left: `${mouseX.value + 12}px`,
top: `${mouseY.value + 12}px`,
}))
const startHover = (e: MouseEvent) => {
mouseX.value = e.clientX
mouseY.value = e.clientY
// Clear any pending deactivation
if (globalDeactivateTimer) {
clearTimeout(globalDeactivateTimer)
globalDeactivateTimer = null
}
const delay = globalActive ? 0 : (props.delay ?? 800)
hoverTimer = setTimeout(() => {
visible.value = true
globalActive = true
}, delay)
}
const updatePosition = (e: MouseEvent) => {
mouseX.value = e.clientX
mouseY.value = e.clientY
}
const endHover = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = null
}
visible.value = false
// Deactivate global state after a short delay if no new tooltip started
if (globalDeactivateTimer) clearTimeout(globalDeactivateTimer)
globalDeactivateTimer = setTimeout(() => {
globalActive = false
}, 500)
}
defineExpose({
startHover,
updatePosition,
endHover,
})
</script>
<style scoped>
.cursor-tooltip {
position: fixed;
z-index: 10000;
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color);
background-color: var(--accent-color);
color: var(--primary-color);
white-space: nowrap;
pointer-events: none;
font-size: 1rem;
}
</style>
-1
View File
@@ -10,7 +10,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 cog from '@/assets/svg/cog.svg' import cog from '@/assets/svg/cog.svg'
import { exists } from '@/utils/fileutil' import { exists } from '@/utils/fileutil'
+2 -2
View File
@@ -17,7 +17,7 @@
<td class="name"> <td class="name">
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" /> <FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
</td> </td>
<FileModified :doc=editing :key=nowkey /> <FileModified :doc=editing :now=nowkey />
<FileSize :doc=editing /> <FileSize :doc=editing />
<td class="menu"></td> <td class="menu"></td>
</tr> </tr>
@@ -55,7 +55,7 @@
<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>
<FileModified :doc=doc :key=nowkey /> <FileModified :doc=doc :now=nowkey />
<FileSize :doc=doc /> <FileSize :doc=doc />
<td class="menu"> <td class="menu">
<button tabindex=-1 @click.stop="contextMenu($event, doc)"></button> <button tabindex=-1 @click.stop="contextMenu($event, doc)"></button>
+25 -8
View File
@@ -1,22 +1,39 @@
<template> <template>
<td class="modified right"> <td class="modified right">
<time :data-tooltip=tooltip :datetime=datetime>{{ doc.modified }}</time> <time
:datetime=datetime
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
>{{ modified }}</time>
<CursorTooltip ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
</td> </td>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { computed } from 'vue' import { formatUnixDate } from '@/utils'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const props = defineProps<{
doc: Doc
now: number
}>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
// Reference props.now to trigger reactivity when time updates
const modified = computed(() => {
props.now // trigger reactivity
return formatUnixDate(props.doc.mtime)
})
const datetime = computed(() => const datetime = computed(() =>
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z') new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
) )
const tooltip = computed(() => const tooltipText = computed(() =>
datetime.value.replace('T', '\n').replace('Z', ' UTC') datetime.value.replace('T', ' ').replace('Z', ' UTC')
) )
const props = defineProps<{
doc: Doc
}>()
</script> </script>
+23 -15
View File
@@ -2,12 +2,8 @@
<div v-if="props.documents.length || editing" class="gallery" ref="gallery"> <div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" /> <GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
<template v-for="(doc, index) in documents" :key=doc.key> <template v-for="(doc, index) in documents" :key=doc.key>
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)"> <BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
<template v-if=showFolderBreadcrumb(index)> <GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" class="folder-change"/>
<div class="spacer"></div>
</template>
</GalleryFigure>
</template> </template>
</div> </div>
</template> </template>
@@ -55,10 +51,12 @@ 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 gallery = ref<HTMLElement>()
const columns = computed(() => { const columnCount = ref(1)
if (!gallery.value) return 1 const updateColumns = () => {
return getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length if (!gallery.value) return
}) columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
}
const columns = computed(() => columnCount.value)
defineExpose({ defineExpose({
newFolder() { newFolder() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
@@ -168,12 +166,21 @@ watchEffect(() => {
focusBreadcrumb() focusBreadcrumb()
} }
}) })
let resizeObserver: ResizeObserver | null = null
onMounted(() => { onMounted(() => {
const active = document.querySelector('.cursor') as HTMLElement | null const active = document.querySelector('.cursor') as HTMLElement | null
if (active) { if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' }) active.scrollIntoView({ block: 'center', behavior: 'instant' })
active.focus() active.focus()
} }
updateColumns()
if (gallery.value) {
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
}) })
const mkdir = (doc: Doc, name: string) => { const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, { const control = connect(controlUrl, {
@@ -205,6 +212,8 @@ const showFolderBreadcrumb = (i: number) => {
const docloc = docs[i].loc const docloc = docs[i].loc
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
} }
const selectionIndeterminate = computed({ const selectionIndeterminate = computed({
get: () => { get: () => {
return ( return (
@@ -254,13 +263,12 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
display: grid; display: grid;
gap: .5em; gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
grid-template-rows: repeat(minmax(auto, 15em));
align-items: end; align-items: end;
} }
.breadcrumb { .folder-indicator {
border-radius: .5em 0 0 .5em; grid-column: 1 / -1;
} }
.spacer { .folder-start {
flex: 0 1000000000 4rem; grid-column-start: 1;
} }
</style> </style>
+24 -3
View File
@@ -4,6 +4,9 @@
@contextmenu.stop @contextmenu.stop
@focus.stop="store.cursor = doc.key" @focus.stop="store.cursor = doc.key"
@click=onclick @click=onclick
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
> >
<figure> <figure>
<slot></slot> <slot></slot>
@@ -15,19 +18,24 @@
</template> </template>
<template v-else> <template v-else>
<SelectBox :doc=doc @click="store.cursor = doc.key"/> <SelectBox :doc=doc @click="store.cursor = doc.key"/>
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span> <span>{{ doc.name }}</span>
<div class=namespacer></div> <div class=namespacer></div>
</template> </template>
</figcaption> </figcaption>
</figure> </figure>
<CursorTooltip ref="tooltip" :text="tooltipText">
<div class="tooltip-name">{{ doc.name }}</div>
<div class="tooltip-details">{{ doc.modified }} {{ doc.sizedisp }}</div>
</CursorTooltip>
</a> </a>
</template> </template>
<script setup lang=ts> <script setup lang=ts>
import { ref } from 'vue' import { ref, computed } 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'
import CursorTooltip from './CursorTooltip.vue'
const store = useMainStore() const store = useMainStore()
type EditingProp = { type EditingProp = {
@@ -40,6 +48,9 @@ const props = defineProps<{
editing?: EditingProp, editing?: EditingProp,
}>() }>()
const m = ref<typeof MediaPreview | null>(null) const m = ref<typeof MediaPreview | null>(null)
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const tooltipText = computed(() => props.doc.key)
const onclick = (ev: Event) => { const onclick = (ev: Event) => {
if (m.value!.play()) ev.preventDefault() if (m.value!.play()) ev.preventDefault()
@@ -48,6 +59,13 @@ const onclick = (ev: Event) => {
</script> </script>
<style scoped> <style scoped>
.tooltip-name {
font-weight: 600;
text-align: center;
}
.tooltip-details {
text-align: center;
}
figure { figure {
max-height: 15em; max-height: 15em;
position: relative; position: relative;
@@ -57,12 +75,15 @@ figure {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: end; justify-content: center;
overflow: hidden; overflow: hidden;
} }
figure > article { figure > article {
flex: 0 0 auto; flex: 0 0 auto;
} }
figure :deep(.video-container) {
height: 15em;
}
.titlespacer { .titlespacer {
flex-shrink: 100000; flex-shrink: 100000;
width: 100%; width: 100%;
+44 -1
View File
@@ -2,7 +2,10 @@
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt=""> <img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
<img v-else-if=doc.img :src=doc.url alt=""> <img v-else-if=doc.img :src=doc.url alt="">
<span v-else-if=doc.dir class="folder icon"></span> <span v-else-if=doc.dir class="folder icon"></span>
<video ref=vid v-else-if=video() :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video> <div v-else-if=video() class="video-container">
<video ref=vid :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
<div class="play-overlay"><PlayIcon /></div>
</div>
<div v-else-if=audio() class="audio icon"> <div v-else-if=audio() class="audio icon">
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio> <audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
</div> </div>
@@ -13,6 +16,7 @@
<script setup lang=ts> <script setup lang=ts>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'
import PlayIcon from '@/assets/svg/play.svg'
const aud = ref<HTMLAudioElement | null>(null) const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null) const vid = ref<HTMLVideoElement | null>(null)
@@ -165,4 +169,43 @@ img::before {
filter: grayscale(1); filter: grayscale(1);
content: '❌'; content: '❌';
} }
.video-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 50%;
max-width: 100%;
max-height: 100%;
}
.video-container video {
width: 100%;
height: 100%;
border-radius: calc(.5em / 8);
object-fit: contain;
}
.play-overlay {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
width: 4em;
height: 4em;
background: rgba(0, 0, 0, 0.2);
border-radius: 50%;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.play-overlay svg {
width: 2em;
height: 2em;
fill: white;
margin-left: 0.25em; /* Visual centering for play triangle */
}
.video-container:hover .play-overlay {
transform: scale(1.1);
}
video[data-playing] + .play-overlay {
opacity: 0;
}
</style> </style>
+47
View File
@@ -13,3 +13,50 @@ export const sorted = (documents: Doc[], order: SortOrder) => {
sorted.sort(ordering[order]) sorted.sort(ordering[order])
return sorted return sorted
} }
/**
* Sort documents while keeping files grouped by their folder.
* - name: folders sorted by folder path, items within by name
* - modified: folders sorted by newest item within results, items within by mtime
* - size: folders sorted by largest file within results, items within by size
*/
export const sortedGrouped = (documents: Doc[], order: SortOrder) => {
if (!order) return documents
const compare = ordering[order]
// Group documents by their folder location
const byFolder = new Map<string, Doc[]>()
for (const doc of documents) {
const folder = doc.loc
if (!byFolder.has(folder)) byFolder.set(folder, [])
byFolder.get(folder)!.push(doc)
}
// Sort items within each folder
for (const docs of byFolder.values()) {
docs.sort(compare)
}
// Find the "best" item in each folder (first after sorting = best according to criteria)
const folderBest = new Map<string, Doc>()
for (const [folder, docs] of byFolder) {
folderBest.set(folder, docs[0])
}
// Sort folders: by path for name sort, by best item for modified/size
const sortedFolders = [...byFolder.keys()].sort((a, b) => {
if (order === 'name') {
return collator.compare(a, b)
}
return compare(folderBest.get(a)!, folderBest.get(b)!)
})
// Flatten back into a single array with folder grouping preserved
const result: Doc[] = []
for (const folder of sortedFolders) {
result.push(...byFolder.get(folder)!)
}
return result
}
+7 -4
View File
@@ -21,7 +21,7 @@ import { watchEffect, ref, computed, watch } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import Router from '@/router/index' import Router from '@/router/index'
import { needleFormat, localeIncludes, collator } from '@/utils' import { needleFormat, localeIncludes, collator } from '@/utils'
import { sorted } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue' import FileExplorer from '@/components/FileExplorer.vue'
const store = useMainStore() const store = useMainStore()
@@ -49,9 +49,9 @@ const documents = computed(() => {
} }
} }
const locsub = loc + '/' const locsub = loc + '/'
// Custom sort override in effect? // Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered const order = store.prefs.sortFiltered
if (order) return sorted(docs, order) if (order) return sortedGrouped(docs, order)
// Sort by relevance - current folder, then subfolders, then others // Sort by relevance - current folder, then subfolders, then others
docs.sort((a, b) => ( docs.sort((a, b) => (
// @ts-ignore // @ts-ignore
@@ -73,7 +73,10 @@ watchEffect(() => {
store.query = props.query store.query = props.query
}) })
watch([() => props.path.join('/'), () => props.query], () => { // Only auto-switch gallery mode when entering a new folder or on initial file list load
watch([() => props.path.join('/'), () => store.document.length], ([path, len], [oldPath, oldLen]) => {
// React to path change or initial document load (0 → non-zero)
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
store.prefs.gallery = documents.value.some(d => d.previewable) store.prefs.gallery = documents.value.some(d => d.previewable)
}, { immediate: true }) }, { immediate: true })
</script> </script>
+2 -5
View File
@@ -7,11 +7,8 @@ import vue from '@vitejs/plugin-vue'
import svgLoader from 'vite-svg-loader' import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
// Development mode:
// bun run dev # Run frontend that proxies to dev_backend
// cista -l :8000 --dev # Run backend
const dev_backend = { const dev_backend = {
target: "http://localhost:8000", target: process.env.CISTA_BACKEND_URL || "http://localhost:8989",
changeOrigin: false, // Use frontend "host" to match "origin" from browser changeOrigin: false, // Use frontend "host" to match "origin" from browser
ws: true, ws: true,
} }
@@ -48,7 +45,7 @@ export default defineConfig({
} }
}, },
build: { build: {
outDir: "../cista/wwwroot", outDir: "../cista/frontend-build",
emptyOutDir: true, emptyOutDir: true,
} }
}) })
+5 -3
View File
@@ -37,7 +37,7 @@ dependencies = [
"pillow-heif>=1.1.0", "pillow-heif>=1.1.0",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"pymupdf>=1.26.3", "pymupdf>=1.26.3",
"sanic>=25.3.0", "sanic>=25.12.0",
"setproctitle>=1.3.6", "setproctitle>=1.3.6",
"stream-zip>=0.0.83", "stream-zip>=0.0.83",
"tomli_w>=1.2.0", "tomli_w>=1.2.0",
@@ -71,8 +71,8 @@ docs = [
source = "vcs" source = "vcs"
[tool.hatch.build] [tool.hatch.build]
artifacts = ["cista/wwwroot"] artifacts = ["cista/frontend-build"]
targets.sdist.hooks.custom.path = "scripts/build-frontend.py" targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py"
targets.sdist.include = [ targets.sdist.include = [
"/cista", "/cista",
] ]
@@ -82,6 +82,7 @@ hooks.vcs.template = """
__version__ = {version!r} __version__ = {version!r}
""" """
only-packages = true only-packages = true
packages = ["cista"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = [ addopts = [
@@ -119,6 +120,7 @@ dev = [
"ruff>=0.8.0", "ruff>=0.8.0",
"mypy>=1.13.0", "mypy>=1.13.0",
"pre-commit>=4.0.0", "pre-commit>=4.0.0",
"httpx>=0.28.1",
] ]
test = [ test = [
"pytest>=8.4.1", "pytest>=8.4.1",
-37
View File
@@ -1,37 +0,0 @@
# noqa: INP001
import os
import shutil
import subprocess
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
super().initialize(version, build_data)
stderr.write(">>> Building Cista frontend\n")
npm = None
bun = shutil.which("bun")
if bun is None:
npm = shutil.which("npm")
if npm is None:
raise RuntimeError(
"Bun or NodeJS `npm` is required for building but neither was found\n Visit https://bun.com/"
)
# npm --prefix doesn't work on Windows, so we chdir instead
os.chdir("frontend")
try:
if npm:
stderr.write("### npm install\n")
subprocess.run([npm, "install"], check=True) # noqa: S603
stderr.write("\n### npm run build\n")
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
else:
assert bun
stderr.write("### bun install\n")
subprocess.run([bun, "install"], check=True) # noqa: S603
stderr.write("\n### bun run build\n")
subprocess.run([bun, "run", "build"], check=True) # noqa: S603
finally:
os.chdir("..")
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env -S uv run
"""Run Vite development server for frontend and Cista backend with auto-reload.
Usage:
uv run scripts/devserver.py [-l <listen>]
Options:
-l LISTEN Listen address for backend (default: from config, or :8000)
Environment:
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
"""
import asyncio
import contextlib
import os
import sys
from pathlib import Path
from sys import stderr
import httpx
from cista import config
from cista.serve import parse_listen
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
DEFAULT_VITE_PORT = 5173
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
BUN_BUG = """\
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
┃ Consider using deno or npm instead for development.
"""
def resolve_frontend_tools(vite_port: int) -> tuple[list[str], list[str], str]:
"""Resolve frontend install and dev commands.
Returns (install_cmd, dev_cmd, tool_name).
Raises SystemExit if tools are not available.
"""
if not (FRONTEND_PATH / "package.json").exists():
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
raise SystemExit(1)
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
dev_cmd, name = find_dev_tool() # noqa # type: ignore
if dev_cmd is None:
if not os.environ.get("JS_RUNTIME"):
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
raise SystemExit(1)
dev_cmd = [*dev_cmd, "--clearScreen=false", f"--port={vite_port}"]
if name == "bun":
stderr.write(BUN_BUG)
return install_cmd, dev_cmd, name
async def wait_for_backend(host: str, port: int):
"""Wait for the backend to be ready by polling the health endpoint."""
max_attempts = 50
url = f"http://{host}:{port}"
async with httpx.AsyncClient() as client:
for attempt in range(max_attempts):
try:
await client.get(url, timeout=1.0)
stderr.write("✓ Backend ready!\n")
return True
except httpx.RequestError:
if attempt == max_attempts - 1:
stderr.write("┃ ⚠️ Backend didn't start in time\n")
return False
await asyncio.sleep(0.1)
return False
async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None:
"""Gracefully terminate a subprocess."""
if proc.returncode is not None:
return
try:
proc.terminate()
except ProcessLookupError:
return
try:
await asyncio.wait_for(proc.wait(), timeout=2)
except TimeoutError:
try:
proc.kill()
except ProcessLookupError:
return
await proc.wait()
async def run_devserver(backend_port: int, cista_args: list[str]) -> None:
"""Run the development server with install, backend, and frontend."""
vite_port = DEFAULT_VITE_PORT
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port)
# Tell the backend where the Vite dev server is (not used yet)
os.environ["CISTA_DEV_FRONTEND_URL"] = f"http://localhost:{vite_port}"
backend_cmd = ["cista", "--dev", *cista_args]
cwd = str(Path(__file__).parent.parent)
frontend_cwd = str(FRONTEND_PATH)
backend_proc: asyncio.subprocess.Process | None = None
install_proc: asyncio.subprocess.Process | None = None
frontend_proc: asyncio.subprocess.Process | None = None
try:
# Start install (concurrent with backend)
stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n")
install_proc = await asyncio.create_subprocess_exec(
*install_cmd, cwd=frontend_cwd
)
await asyncio.sleep(0.1)
# Start backend (concurrent with install)
stderr.write(f">>> {' '.join(backend_cmd)}\n")
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
# Wait for install to complete and backend to be ready
install_task = asyncio.create_task(install_proc.wait(), name="install")
backend_ready_task = asyncio.create_task(
wait_for_backend("localhost", backend_port), name="backend_ready"
)
done, pending = await asyncio.wait(
{install_task, backend_ready_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in done:
if task.get_name() == "install":
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
if pending:
done2, _ = await asyncio.wait(pending)
for task in done2:
if task.get_name() == "install":
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
install_proc = None
# Start Vite dev server
stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n")
frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd)
# Wait for either process to exit
done, pending = await asyncio.wait(
{
asyncio.create_task(backend_proc.wait(), name="backend"),
asyncio.create_task(frontend_proc.wait(), name="frontend"),
},
return_when=asyncio.FIRST_COMPLETED,
)
for t in done:
t.result()
for t in pending:
t.cancel()
except asyncio.CancelledError:
stderr.write("\n✓ Shutting down...\n")
finally:
if frontend_proc is not None:
await _terminate_process(frontend_proc, "frontend")
if install_proc is not None:
await _terminate_process(install_proc, "install")
if backend_proc is not None:
await _terminate_process(backend_proc, "backend")
def main():
# Pass all arguments to cista, parse -l to determine backend port
cista_args = sys.argv[1:]
listen_arg = None
if "-l" in cista_args:
idx = cista_args.index("-l")
if idx + 1 < len(cista_args):
listen_arg = cista_args[idx + 1]
# Load config to get the backend port
config.load_config()
listen = listen_arg or config.config.listen or ":8000"
_, opts = parse_listen(listen)
backend_port = opts.get("port", 8000)
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(run_devserver(backend_port, cista_args))
if __name__ == "__main__":
main()
+34
View File
@@ -0,0 +1,34 @@
"""Hatch build hook for building Vue frontend during package build."""
import subprocess
from pathlib import Path
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
def run(cmd, **kwargs):
"""Run a command and display it."""
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
stderr.write(f"### {' '.join(display_cmd)}\n")
subprocess.run(cmd, check=True, **kwargs)
class CustomBuildHook(BuildHookInterface):
"""Build hook that compiles Vue frontend before packaging."""
def initialize(self, version, build_data):
super().initialize(version, build_data)
stderr.write(">>> Building the frontend\n")
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
try:
run(install_cmd, cwd="frontend")
stderr.write("\n")
run(build_cmd, cwd="frontend")
except Exception as e:
stderr.write(f"Error occurred while building frontend: {e}\n")
raise
+87
View File
@@ -0,0 +1,87 @@
"""Shared utilities for build and dev scripts."""
import os
import shutil
from pathlib import Path
from sys import stderr
def find_js_runtime() -> tuple[str, str] | None:
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Returns None if no runtime is found.
"""
options = ["deno", "npm", "bun"]
# Check for JS_RUNTIME environment variable
if js_runtime_env := os.environ.get("JS_RUNTIME"):
js_runtime = js_runtime_env
js_path = Path(js_runtime)
runtime_name = js_path.name
# Map node to npm
if runtime_name == "node":
runtime_name = "npm"
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
for option in options:
if option == runtime_name or runtime_name.startswith(option):
tool = shutil.which(js_runtime)
if tool is None:
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not found\n")
return None
return tool, option
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not recognized\n")
return None
# Auto-detect
for option in options:
if tool := shutil.which(option):
return tool, option
return None
def find_build_tool():
"""Find JavaScript runtime and construct install/build commands.
Returns (install_cmd, build_cmd) tuples of command lists.
Raises RuntimeError if no runtime is found.
"""
install = {
"deno": ("install", "--allow-scripts=npm:vue-demi"),
"npm": ("install",),
"bun": ("--bun", "install"),
}
# Run vite directly for deno to avoid npm-run-all2/run-p issues
build = {
"deno": ("run", "-A", "npm:vite", "build"),
"npm": ("run", "build"),
"bun": ("--bun", "run", "build"),
}
result = find_js_runtime()
if result is None:
raise RuntimeError(
"Deno, npm or Bun is required for building but none was found"
)
tool, name = result
return [tool, *install[name]], [tool, *build[name]]
def find_dev_tool():
"""Find JavaScript runtime and construct dev command.
Returns (dev_cmd, tool_name) or (None, None) if not found.
"""
dev_args = {
"deno": ("run", "dev", "--"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
result = find_js_runtime()
if result is None:
return None, None
tool, name = result
return [tool, *dev_args[name]], name