Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146497d731 | ||
|
|
442816a0ae | ||
|
|
d32afa6016 | ||
|
|
fa60c962c4 | ||
|
|
e55e11b399 | ||
|
|
b6c21152e7 | ||
|
|
f354fc5c71 | ||
|
|
5bda809921 | ||
|
|
2cc92cd786 | ||
|
|
ba6380e71e | ||
|
|
0d853032bf |
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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("..")
|
|
||||||
Executable
+210
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user