Compare commits
5 Commits
e56cc47105
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62388eb555 | ||
|
|
53778543bf | ||
|
|
8dda230510 | ||
|
|
696e3ab568 | ||
|
|
85ac12ad33 |
@@ -16,7 +16,7 @@ from sanic.exceptions import Forbidden, NotFound
|
|||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
from stream_zip import ZIP_AUTO, stream_zip
|
from stream_zip import ZIP_AUTO, stream_zip
|
||||||
|
|
||||||
from cista import auth, config, session, watching
|
from cista import auth, config, preview, session, watching
|
||||||
from cista.api import bp
|
from cista.api import bp
|
||||||
from cista.util.apphelpers import handle_sanic_exception
|
from cista.util.apphelpers import handle_sanic_exception
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ sanic.helpers._ENTITY_HEADERS = frozenset()
|
|||||||
|
|
||||||
app = Sanic("cista", strict_slashes=True)
|
app = Sanic("cista", strict_slashes=True)
|
||||||
app.blueprint(auth.bp)
|
app.blueprint(auth.bp)
|
||||||
|
app.blueprint(preview.bp)
|
||||||
app.blueprint(bp)
|
app.blueprint(bp)
|
||||||
app.exception(Exception)(handle_sanic_exception)
|
app.exception(Exception)(handle_sanic_exception)
|
||||||
|
|
||||||
|
|||||||
52
cista/preview.py
Normal file
52
cista/preview.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from sanic import Blueprint, raw
|
||||||
|
from sanic.exceptions import Forbidden, NotFound
|
||||||
|
from sanic.log import logger
|
||||||
|
|
||||||
|
from cista import config
|
||||||
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<path:path>")
|
||||||
|
async def preview(req, path):
|
||||||
|
"""Preview a file"""
|
||||||
|
width = int(req.query_string) if req.query_string else 1024
|
||||||
|
rel = PurePosixPath(sanitize(unquote(path)))
|
||||||
|
path = config.config.path / rel
|
||||||
|
if not path.is_file():
|
||||||
|
raise NotFound("File not found")
|
||||||
|
size = path.lstat().st_size
|
||||||
|
if size > 20 * 10**6:
|
||||||
|
raise Forbidden("File too large")
|
||||||
|
img = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
req.app.ctx.threadexec, process_image, path, width
|
||||||
|
)
|
||||||
|
return raw(img, content_type="image/webp")
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(path, maxsize):
|
||||||
|
img = Image.open(path)
|
||||||
|
w, h = img.size
|
||||||
|
img.thumbnail((min(w, maxsize), min(h, maxsize)))
|
||||||
|
# Fix rotation based on EXIF data
|
||||||
|
try:
|
||||||
|
rotate_values = {3: 180, 6: 270, 8: 90}
|
||||||
|
exif = img._getexif()
|
||||||
|
if exif:
|
||||||
|
orientation = exif.get(274)
|
||||||
|
if orientation in rotate_values:
|
||||||
|
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
|
||||||
|
img = img.rotate(rotate_values[orientation], expand=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error rotating preview image: {e}")
|
||||||
|
# Save as webp
|
||||||
|
imgdata = io.BytesIO()
|
||||||
|
img.save(imgdata, format="webp", quality=70, method=6)
|
||||||
|
return imgdata.getvalue()
|
||||||
@@ -93,7 +93,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
// G toggles Gallery
|
// G toggles Gallery
|
||||||
else if (!input && keyup && event.key === 'g') {
|
else if (!input && keyup && event.key === 'g') {
|
||||||
store.gallery = !store.gallery
|
store.prefs.gallery = !store.prefs.gallery
|
||||||
}
|
}
|
||||||
// Keys Backquote-1-2-3 to sort columns
|
// Keys Backquote-1-2-3 to sort columns
|
||||||
else if (
|
else if (
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
@contextmenu.stop
|
@contextmenu.stop
|
||||||
@focus.stop="store.cursor = doc.key"
|
@focus.stop="store.cursor = doc.key"
|
||||||
@click="ev => {
|
@click="ev => {
|
||||||
console.log('Gallery click', doc.key, store.cursor, !!media)
|
|
||||||
if (m!.play()) ev.preventDefault()
|
if (m!.play()) ev.preventDefault()
|
||||||
store.cursor = doc.key
|
store.cursor = doc.key
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
|
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
|
||||||
<SvgButton name="eye" @click="store.gallery = !store.gallery" />
|
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" />
|
||||||
<SvgButton name="cog" @click="settingsMenu" />
|
<SvgButton name="cog" @click="settingsMenu" />
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<img v-if=doc.img :src=doc.url alt="">
|
<img v-if=doc.img :src="preview() ? doc.previewurl : doc.url" alt="">
|
||||||
<span v-else-if=doc.dir class="folder icon"></span>
|
<span v-else-if=doc.dir class="folder icon"></span>
|
||||||
<video ref=vid v-else-if=video() :src=doc.url controls preload=none @click.prevent>📄</video>
|
<video ref=vid v-else-if=video() :src=doc.url controls preload=none @click.prevent>📄</video>
|
||||||
<audio ref=aud v-else-if=audio() :src=doc.url controls preload=metadata @click.stop>📄</audio>
|
<audio ref=aud v-else-if=audio() :src=doc.url controls preload=metadata @click.stop>📄</audio>
|
||||||
@@ -32,8 +32,11 @@ defineExpose({
|
|||||||
|
|
||||||
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
|
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
|
||||||
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
|
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
|
||||||
const embed = () => ['txt', 'py', 'html', 'css', 'js', 'json', 'xml', 'csv', 'tsv'].includes(props.doc.ext)
|
|
||||||
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
||||||
|
const preview = () => (
|
||||||
|
props.doc.size > 500000 &&
|
||||||
|
['png', 'bmp', 'ico', 'webp', 'avif', 'jpg', 'jpeg'].includes(props.doc.ext)
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export class Doc {
|
|||||||
const ext = this.name.split('.').pop()?.toLowerCase()
|
const ext = this.name.split('.').pop()?.toLowerCase()
|
||||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'].includes(ext || '')
|
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'].includes(ext || '')
|
||||||
}
|
}
|
||||||
|
get previewurl(): string {
|
||||||
|
return this.url.replace(/^\/files/, '/preview')
|
||||||
|
}
|
||||||
get ext(): string {
|
get ext(): string {
|
||||||
const ext = this.name.split('.').pop()
|
const ext = this.name.split('.').pop()
|
||||||
return ext ? ext.toLowerCase() : ''
|
return ext ? ext.toLowerCase() : ''
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export const useMainStore = defineStore({
|
|||||||
fileExplorer: null as any,
|
fileExplorer: null as any,
|
||||||
error: '' as string,
|
error: '' as string,
|
||||||
connected: false,
|
connected: false,
|
||||||
gallery: false,
|
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
server: {} as Record<string, any>,
|
server: {} as Record<string, any>,
|
||||||
prefs: {
|
prefs: {
|
||||||
|
gallery: false,
|
||||||
sortListing: '' as SortOrder,
|
sortListing: '' as SortOrder,
|
||||||
sortFiltered: '' as SortOrder,
|
sortFiltered: '' as SortOrder,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
||||||
<component :is="cog" class="cog"/>
|
<component :is="cog" class="cog"/>
|
||||||
<p v-if="!store.connected">No Connection</p>
|
<p v-if="!store.connected">No Connection</p>
|
||||||
<p v-else-if="store.document.length === 0">Waiting for Files</p>
|
<p v-else-if="store.document.length === 0">Waiting for File List</p>
|
||||||
<p v-else-if="store.query">No matches!</p>
|
<p v-else-if="store.query">No matches!</p>
|
||||||
<p v-else-if="!store.document.some(doc => `${doc.loc}/${doc.name}` === props.path.join('/'))">Folder not found.</p>
|
<p v-else-if="!store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === props.path.join('/'))">Folder not found.</p>
|
||||||
<p v-else>Empty folder</p>
|
<p v-else>Empty folder</p>
|
||||||
</div>
|
</div>
|
||||||
<Gallery
|
<Gallery
|
||||||
v-else-if="store.gallery"
|
v-else-if="store.prefs.gallery"
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`gallery-${Router.currentRoute.value.path}`"
|
:key="`gallery-${Router.currentRoute.value.path}`"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
<div v-if="!store.gallery && documents.some(doc => doc.img)" class="suggest-gallery">
|
<div v-if="!store.prefs.gallery && documents.some(doc => doc.img)" class="suggest-gallery">
|
||||||
<p>Media files found. Would you like a gallery view?</p>
|
<p>Media files found. Would you like a gallery view?</p>
|
||||||
<SvgButton name="eye" taborder=0 @click="() => { store.gallery = true }">Gallery</SvgButton>
|
<SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }">Gallery</SvgButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default defineConfig({
|
|||||||
"/login": dev_backend,
|
"/login": dev_backend,
|
||||||
"/logout": dev_backend,
|
"/logout": dev_backend,
|
||||||
"/zip": dev_backend,
|
"/zip": dev_backend,
|
||||||
|
"/preview": dev_backend,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ dependencies = [
|
|||||||
"msgspec",
|
"msgspec",
|
||||||
"natsort",
|
"natsort",
|
||||||
"pathvalidate",
|
"pathvalidate",
|
||||||
|
"pillow",
|
||||||
"pyjwt",
|
"pyjwt",
|
||||||
"sanic",
|
"sanic",
|
||||||
"stream-zip",
|
"stream-zip",
|
||||||
|
|||||||
Reference in New Issue
Block a user