Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146497d731 | ||
|
|
442816a0ae | ||
|
|
d32afa6016 | ||
|
|
fa60c962c4 | ||
|
|
e55e11b399 | ||
|
|
b6c21152e7 | ||
|
|
f354fc5c71 | ||
|
|
5bda809921 | ||
|
|
2cc92cd786 | ||
|
|
ba6380e71e | ||
|
|
0d853032bf | ||
|
|
1cb512e65d | ||
|
|
972aaee9fe | ||
|
|
055eaa8a21 |
+1
-1
@@ -4,5 +4,5 @@
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
/cista/_version.py
|
||||
/cista/wwwroot/*
|
||||
/cista/frontend-build/
|
||||
/dist
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@ fileserver = FileServer()
|
||||
|
||||
|
||||
@bp.before_server_start
|
||||
async def start_fileserver(app, _):
|
||||
async def start_fileserver(app):
|
||||
await fileserver.start()
|
||||
|
||||
|
||||
@bp.after_server_stop
|
||||
async def stop_fileserver(app, _):
|
||||
async def stop_fileserver(app):
|
||||
await fileserver.stop()
|
||||
|
||||
|
||||
|
||||
+6
-6
@@ -36,19 +36,19 @@ setproctitle("cista-main")
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
async def main_start(app, loop):
|
||||
async def main_start(app):
|
||||
config.load_config()
|
||||
setproctitle(f"cista {config.config.path.name}")
|
||||
workers = max(2, min(8, cpu_count()))
|
||||
app.ctx.threadexec = ThreadPoolExecutor(
|
||||
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)
|
||||
@app.before_server_stop
|
||||
async def main_stop(app, loop):
|
||||
async def main_stop(app):
|
||||
quit.set()
|
||||
watching.stop(app)
|
||||
app.ctx.threadexec.shutdown()
|
||||
@@ -75,7 +75,7 @@ async def use_session(req):
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
def http_fileserver(app, _):
|
||||
def http_fileserver(app):
|
||||
bp = Blueprint("fileserver")
|
||||
bp.on_request(auth.verify)
|
||||
bp.static(
|
||||
@@ -93,9 +93,9 @@ www = {}
|
||||
|
||||
def _load_wwwroot(www):
|
||||
wwwnew = {}
|
||||
base = Path(__file__).with_name("wwwroot")
|
||||
base = Path(__file__).with_name("frontend-build")
|
||||
paths = [PurePath()]
|
||||
zstd = ZstdCompressor(level=10)
|
||||
zstd = ZstdCompressor(level=18)
|
||||
while paths:
|
||||
path = paths.pop(0)
|
||||
current = base / path
|
||||
|
||||
@@ -271,6 +271,18 @@ async def update_user(request, username):
|
||||
return json(response)
|
||||
|
||||
|
||||
@bp.delete("/users/<username>")
|
||||
async def delete_user(request, username):
|
||||
verify(request, privileged=True)
|
||||
if username not in config.config.users:
|
||||
raise BadRequest("User does not exist")
|
||||
try:
|
||||
config.del_user(username)
|
||||
except Exception as e:
|
||||
raise BadRequest(str(e)) from e
|
||||
return json({"message": f"User {username} deleted"})
|
||||
|
||||
|
||||
@bp.put("/config/public")
|
||||
async def update_public(request):
|
||||
verify(request, privileged=True)
|
||||
|
||||
+63
-40
@@ -13,7 +13,7 @@ import fitz # PyMuPDF
|
||||
import numpy as np
|
||||
import pillow_heif
|
||||
from PIL import Image
|
||||
from sanic import Blueprint, empty, raw
|
||||
from sanic import Blueprint, empty, raw, redirect
|
||||
from sanic.exceptions import NotFound
|
||||
from sanic.log import logger
|
||||
|
||||
@@ -43,12 +43,12 @@ async def preview(req, path):
|
||||
maxzoom = float(req.args.get("zoom", 2.0))
|
||||
quality = int(req.args.get("q", 60))
|
||||
rel = PurePosixPath(sanitize(unquote(path)))
|
||||
path = config.config.path / rel
|
||||
stat = path.lstat()
|
||||
filepath = config.config.path / rel
|
||||
stat = filepath.lstat()
|
||||
etag = config.derived_secret(
|
||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||
).hex()
|
||||
savename = PurePosixPath(path.name).with_suffix(".avif")
|
||||
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||
headers = {
|
||||
"etag": etag,
|
||||
"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
|
||||
return empty(304, headers=headers)
|
||||
|
||||
if not path.is_file():
|
||||
if not filepath.is_file():
|
||||
raise NotFound("File not found")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def dispatch(path, quality, maxsize, maxzoom):
|
||||
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||
type, _ = mimetypes.guess_type(path.name)
|
||||
if type and type.startswith("video/"):
|
||||
return process_video(path, quality=quality, maxsize=maxsize)
|
||||
return process_image(path, quality=quality, maxsize=maxsize)
|
||||
try:
|
||||
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||
type, _ = mimetypes.guess_type(path.name)
|
||||
if type and type.startswith("video/"):
|
||||
return process_video(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):
|
||||
@@ -121,7 +129,7 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||
w, h = page.rect[2:4]
|
||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||
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_save_start = perf_counter()
|
||||
@@ -166,35 +174,49 @@ def process_video(path, *, maxsize, quality):
|
||||
new_height = int(frame.height * scale_factor)
|
||||
frame = frame.reformat(width=new_width, height=new_height)
|
||||
|
||||
# Simple rotation detection and logging
|
||||
# Apply EXIF rotation if present
|
||||
if frame.rotation:
|
||||
try:
|
||||
fplanes = frame.to_ndarray()
|
||||
# Split into Y, U, V planes of proper dimensions
|
||||
planes = [
|
||||
fplanes[: frame.height],
|
||||
fplanes[frame.height : frame.height + frame.height // 4].reshape(
|
||||
frame.height // 2, frame.width // 2
|
||||
),
|
||||
fplanes[frame.height + frame.height // 4 :].reshape(
|
||||
frame.height // 2, frame.width // 2
|
||||
),
|
||||
]
|
||||
# Rotate
|
||||
planes = [np.rot90(p, frame.rotation // 90) for p in planes]
|
||||
# Restore PyAV format
|
||||
planes = np.hstack([p.flat for p in planes]).reshape(
|
||||
-1, planes[0].shape[1]
|
||||
)
|
||||
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
|
||||
del planes, fplanes
|
||||
except Exception as e:
|
||||
if "not yet supported" in str(e):
|
||||
logger.warning(
|
||||
f"Not rotating {path.name} preview image by {frame.rotation}°:\n PyAV: {e}"
|
||||
# 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:
|
||||
fplanes = frame.to_ndarray()
|
||||
# Split into Y, U, V planes of proper dimensions
|
||||
planes = [
|
||||
fplanes[: frame.height],
|
||||
fplanes[
|
||||
frame.height : frame.height + frame.height // 4
|
||||
].reshape(frame.height // 2, frame.width // 2),
|
||||
fplanes[frame.height + frame.height // 4 :].reshape(
|
||||
frame.height // 2, frame.width // 2
|
||||
),
|
||||
]
|
||||
# Rotate each plane by 180°
|
||||
planes = [np.rot90(p, 2) for p in planes]
|
||||
# Restore PyAV format
|
||||
planes = np.hstack([p.flat for p in planes]).reshape(
|
||||
-1, planes[0].shape[1]
|
||||
)
|
||||
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
|
||||
del planes, fplanes
|
||||
except Exception as e:
|
||||
logger.exception(f"Error rotating video frame by 180°: {e}")
|
||||
elif k in (1, 3):
|
||||
# 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_save_start = perf_counter()
|
||||
@@ -211,6 +233,7 @@ def process_video(path, *, maxsize, quality):
|
||||
assert isinstance(ostream, av.VideoStream)
|
||||
ostream.width = frame.width
|
||||
ostream.height = frame.height
|
||||
ostream.pix_fmt = frame.format.name
|
||||
icc = istream.codec_context
|
||||
occ = ostream.codec_context
|
||||
|
||||
|
||||
+2
-2
@@ -440,14 +440,14 @@ def watcher_poll(loop):
|
||||
quit.wait(0.1 + 8 * dur)
|
||||
|
||||
|
||||
def start(app, loop):
|
||||
def start(app):
|
||||
global rootpath
|
||||
config.load_config()
|
||||
rootpath = config.config.path
|
||||
use_inotify = sys.platform == "linux"
|
||||
app.ctx.watcher = threading.Thread(
|
||||
target=watcher_inotify if use_inotify else watcher_poll,
|
||||
args=[loop],
|
||||
args=[app.loop],
|
||||
# Descriptive name for system monitoring
|
||||
name=f"cista-watcher {rootpath}",
|
||||
)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang=en>
|
||||
<meta charset=UTF-8>
|
||||
<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="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
margin: 0 .5rem 0 1rem !important;
|
||||
}
|
||||
body#app {
|
||||
position: static !important;
|
||||
height: auto !important;
|
||||
}
|
||||
main {
|
||||
@@ -165,6 +166,11 @@ body {
|
||||
font-family: 'Roboto';
|
||||
color: var(--primary-color);
|
||||
margin: 0;
|
||||
/* Prevent any scrolling on body */
|
||||
overflow: hidden;
|
||||
/* Fallback for older browsers */
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
tbody .size,
|
||||
tbody .modified {
|
||||
@@ -214,12 +220,14 @@ table {
|
||||
gap: 0;
|
||||
}
|
||||
body#app {
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
main {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0; /* Allow flex child to shrink below content size */
|
||||
padding-bottom: 3em; /* convenience space on the bottom */
|
||||
overflow-y: scroll;
|
||||
text-align: center;
|
||||
@@ -237,6 +245,7 @@ header nav.headermain {
|
||||
z-index: 101;
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
padding: .5rem 1rem;
|
||||
@@ -248,9 +257,6 @@ header nav.headermain {
|
||||
white-space: pre;
|
||||
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 {
|
||||
from {
|
||||
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>
|
||||
@@ -78,7 +78,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
||||
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create directory', hdir, error)
|
||||
return
|
||||
throw new Error(`Failed to create directory ${hdir}: ${error}`)
|
||||
}
|
||||
console.log('Created', hdir)
|
||||
}
|
||||
@@ -90,37 +90,42 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
||||
fileHandle = await h.getFileHandle(name, { create: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create file', rel, full, hdir + name, error)
|
||||
return
|
||||
throw new Error(`Failed to create file ${hdir + name}: ${error}`)
|
||||
}
|
||||
const writable = await fileHandle.createWritable()
|
||||
const url = `/files/${rel}`
|
||||
console.log('Fetching', url)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
if (res.body) {
|
||||
++store.dprogress.fileidx
|
||||
const reader = res.body.getReader()
|
||||
await writable.truncate(0)
|
||||
store.error = "Direct download."
|
||||
store.dprogress.tlast = Date.now()
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
await writable.write(value)
|
||||
const now = Date.now()
|
||||
const size = value.byteLength
|
||||
store.dprogress.xfer += size
|
||||
store.dprogress.filepos += size
|
||||
store.dprogress.statbytes += size
|
||||
store.dprogress.statdur += now - store.dprogress.tlast
|
||||
store.dprogress.tlast = now
|
||||
try {
|
||||
const writable = await fileHandle.createWritable()
|
||||
const url = `/files/${rel}`
|
||||
console.log('Fetching', url)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
|
||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
if (res.body) {
|
||||
++store.dprogress.fileidx
|
||||
const reader = res.body.getReader()
|
||||
await writable.truncate(0)
|
||||
store.error = "Direct download."
|
||||
store.dprogress.tlast = Date.now()
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
await writable.write(value)
|
||||
const now = Date.now()
|
||||
const size = value.byteLength
|
||||
store.dprogress.xfer += size
|
||||
store.dprogress.filepos += size
|
||||
store.dprogress.statbytes += size
|
||||
store.dprogress.statdur += now - store.dprogress.tlast
|
||||
store.dprogress.tlast = now
|
||||
}
|
||||
}
|
||||
await writable.close()
|
||||
console.log('Saved', hdir + name)
|
||||
} catch (error) {
|
||||
console.error('Failed to write file', hdir + name, error)
|
||||
throw new Error(`Failed to write file ${hdir + name}: ${error}`)
|
||||
}
|
||||
await writable.close()
|
||||
console.log('Saved', hdir + name)
|
||||
}
|
||||
statReset()
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import cog from '@/assets/svg/cog.svg'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<td class="name">
|
||||
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
|
||||
</td>
|
||||
<FileModified :doc=editing :key=nowkey />
|
||||
<FileModified :doc=editing :now=nowkey />
|
||||
<FileSize :doc=editing />
|
||||
<td class="menu"></td>
|
||||
</tr>
|
||||
@@ -55,7 +55,7 @@
|
||||
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||
</template>
|
||||
</td>
|
||||
<FileModified :doc=doc :key=nowkey />
|
||||
<FileModified :doc=doc :now=nowkey />
|
||||
<FileSize :doc=doc />
|
||||
<td class="menu">
|
||||
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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(() =>
|
||||
new Date(1000 * props.doc.mtime).toISOString().replace('.000Z', 'Z')
|
||||
)
|
||||
|
||||
const tooltip = computed(() =>
|
||||
datetime.value.replace('T', '\n').replace('Z', ' UTC')
|
||||
const tooltipText = computed(() =>
|
||||
datetime.value.replace('T', ' ').replace('Z', ' UTC')
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
<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}" />
|
||||
<template v-for="(doc, index) in documents" :key=doc.key>
|
||||
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)">
|
||||
<template v-if=showFolderBreadcrumb(index)>
|
||||
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" class="folder-change"/>
|
||||
<div class="spacer"></div>
|
||||
</template>
|
||||
</GalleryFigure>
|
||||
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
||||
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
|
||||
</template>
|
||||
</div>
|
||||
</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
|
||||
}
|
||||
const gallery = ref<HTMLElement>()
|
||||
const columns = computed(() => {
|
||||
if (!gallery.value) return 1
|
||||
return getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
|
||||
})
|
||||
const columnCount = ref(1)
|
||||
const updateColumns = () => {
|
||||
if (!gallery.value) return
|
||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
|
||||
}
|
||||
const columns = computed(() => columnCount.value)
|
||||
defineExpose({
|
||||
newFolder() {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
@@ -168,12 +166,21 @@ watchEffect(() => {
|
||||
focusBreadcrumb()
|
||||
}
|
||||
})
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
onMounted(() => {
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
}
|
||||
updateColumns()
|
||||
if (gallery.value) {
|
||||
resizeObserver = new ResizeObserver(updateColumns)
|
||||
resizeObserver.observe(gallery.value)
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
const mkdir = (doc: Doc, name: string) => {
|
||||
const control = connect(controlUrl, {
|
||||
@@ -205,6 +212,8 @@ const showFolderBreadcrumb = (i: number) => {
|
||||
const docloc = docs[i].loc
|
||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
|
||||
}
|
||||
|
||||
|
||||
const selectionIndeterminate = computed({
|
||||
get: () => {
|
||||
return (
|
||||
@@ -254,13 +263,12 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||
grid-template-rows: repeat(minmax(auto, 15em));
|
||||
align-items: end;
|
||||
}
|
||||
.breadcrumb {
|
||||
border-radius: .5em 0 0 .5em;
|
||||
.folder-indicator {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.spacer {
|
||||
flex: 0 1000000000 4rem;
|
||||
.folder-start {
|
||||
grid-column-start: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
@contextmenu.stop
|
||||
@focus.stop="store.cursor = doc.key"
|
||||
@click=onclick
|
||||
@mouseenter="tooltip?.startHover"
|
||||
@mousemove="tooltip?.updatePosition"
|
||||
@mouseleave="tooltip?.endHover"
|
||||
>
|
||||
<figure>
|
||||
<slot></slot>
|
||||
@@ -15,19 +18,24 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
</template>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||
<div class="tooltip-name">{{ doc.name }}</div>
|
||||
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||
</CursorTooltip>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import MediaPreview from '@/components/MediaPreview.vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
type EditingProp = {
|
||||
@@ -40,6 +48,9 @@ const props = defineProps<{
|
||||
editing?: EditingProp,
|
||||
}>()
|
||||
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) => {
|
||||
if (m.value!.play()) ev.preventDefault()
|
||||
@@ -48,6 +59,13 @@ const onclick = (ev: Event) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tooltip-name {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.tooltip-details {
|
||||
text-align: center;
|
||||
}
|
||||
figure {
|
||||
max-height: 15em;
|
||||
position: relative;
|
||||
@@ -57,12 +75,15 @@ figure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
figure > article {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
figure :deep(.video-container) {
|
||||
height: 15em;
|
||||
}
|
||||
.titlespacer {
|
||||
flex-shrink: 100000;
|
||||
width: 100%;
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
|
||||
<img v-else-if=doc.img :src=doc.url alt="">
|
||||
<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">
|
||||
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
|
||||
</div>
|
||||
@@ -13,6 +16,7 @@
|
||||
<script setup lang=ts>
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Doc } from '@/repositories/Document'
|
||||
import PlayIcon from '@/assets/svg/play.svg'
|
||||
|
||||
const aud = ref<HTMLAudioElement | null>(null)
|
||||
const vid = ref<HTMLVideoElement | null>(null)
|
||||
@@ -165,4 +169,43 @@ img::before {
|
||||
filter: grayscale(1);
|
||||
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>
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
</div>
|
||||
<h3>Users</h3>
|
||||
<button @click="addUser" class="button" title="Add new user">➕ Add User</button>
|
||||
<div v-if="success" class="success-message">
|
||||
<div v-if="success" class="success-message" @click="copySuccess(false)">
|
||||
{{ success }}
|
||||
<button @click="copySuccess" class="button small" title="Copy to clipboard">�</button>
|
||||
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
|
||||
</div>
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
@@ -70,6 +70,7 @@ const loading = ref(true)
|
||||
const users = ref<User[]>([])
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const copyButtonText = ref('📋')
|
||||
const serverSettings = reactive({
|
||||
public: false
|
||||
})
|
||||
@@ -170,11 +171,25 @@ const deleteUserAction = async (username: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const copySuccess = async () => {
|
||||
const passwordMatch = success.value.match(/Password: (.+)/)
|
||||
const copySuccess = async (isButtonClick: boolean = false) => {
|
||||
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
|
||||
if (passwordMatch) {
|
||||
await navigator.clipboard.writeText(passwordMatch[1])
|
||||
// Maybe flash or something, but for now just copy
|
||||
if (isButtonClick) {
|
||||
// Show "Copied!" indication on button
|
||||
copyButtonText.value = '✅ Copied!'
|
||||
// Hide password and button immediately after copying
|
||||
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
|
||||
success.value = baseMessage
|
||||
// Hide the entire message after 3 seconds
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
copyButtonText.value = '📋'
|
||||
}, 3000)
|
||||
} else {
|
||||
// Just hide the message when clicking elsewhere
|
||||
success.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,3 +13,50 @@ export const sorted = (documents: Doc[], order: SortOrder) => {
|
||||
sorted.sort(ordering[order])
|
||||
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 Router from '@/router/index'
|
||||
import { needleFormat, localeIncludes, collator } from '@/utils'
|
||||
import { sorted } from '@/utils/docsort'
|
||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||
import FileExplorer from '@/components/FileExplorer.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
@@ -49,9 +49,9 @@ const documents = computed(() => {
|
||||
}
|
||||
}
|
||||
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
|
||||
if (order) return sorted(docs, order)
|
||||
if (order) return sortedGrouped(docs, order)
|
||||
// Sort by relevance - current folder, then subfolders, then others
|
||||
docs.sort((a, b) => (
|
||||
// @ts-ignore
|
||||
@@ -73,8 +73,11 @@ watchEffect(() => {
|
||||
store.query = props.query
|
||||
})
|
||||
|
||||
watch(documents, (docs) => {
|
||||
store.prefs.gallery = docs.some(d => d.previewable)
|
||||
// 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)
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,11 +7,8 @@ import vue from '@vitejs/plugin-vue'
|
||||
import svgLoader from 'vite-svg-loader'
|
||||
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 = {
|
||||
target: "http://localhost:8000",
|
||||
target: process.env.CISTA_BACKEND_URL || "http://localhost:8989",
|
||||
changeOrigin: false, // Use frontend "host" to match "origin" from browser
|
||||
ws: true,
|
||||
}
|
||||
@@ -48,7 +45,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: "../cista/wwwroot",
|
||||
outDir: "../cista/frontend-build",
|
||||
emptyOutDir: true,
|
||||
}
|
||||
})
|
||||
|
||||
+5
-3
@@ -37,7 +37,7 @@ dependencies = [
|
||||
"pillow-heif>=1.1.0",
|
||||
"pyjwt>=2.10.1",
|
||||
"pymupdf>=1.26.3",
|
||||
"sanic>=25.3.0",
|
||||
"sanic>=25.12.0",
|
||||
"setproctitle>=1.3.6",
|
||||
"stream-zip>=0.0.83",
|
||||
"tomli_w>=1.2.0",
|
||||
@@ -71,8 +71,8 @@ docs = [
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.build]
|
||||
artifacts = ["cista/wwwroot"]
|
||||
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
||||
artifacts = ["cista/frontend-build"]
|
||||
targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py"
|
||||
targets.sdist.include = [
|
||||
"/cista",
|
||||
]
|
||||
@@ -82,6 +82,7 @@ hooks.vcs.template = """
|
||||
__version__ = {version!r}
|
||||
"""
|
||||
only-packages = true
|
||||
packages = ["cista"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = [
|
||||
@@ -119,6 +120,7 @@ dev = [
|
||||
"ruff>=0.8.0",
|
||||
"mypy>=1.13.0",
|
||||
"pre-commit>=4.0.0",
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
test = [
|
||||
"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