Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af35e0480a | ||
|
|
5717486197 | ||
|
|
0061fc54ae | ||
|
|
4eefe83072 | ||
|
|
f578a50007 | ||
|
|
f40d9c1abd | ||
|
|
3d8845cf99 | ||
|
|
87e1443e7d | ||
|
|
f45c57e901 |
+11
-2
@@ -25,14 +25,22 @@ def create_banner():
|
||||
"""
|
||||
|
||||
|
||||
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
|
||||
def create_startup_box(
|
||||
*, folder, url, unix=None, dev=False, paskia_url=None, public=False
|
||||
):
|
||||
"""Create a framed startup box with server information."""
|
||||
title = f"Cista {cista.__version__}"
|
||||
listen = unix if unix else url
|
||||
location = f"{folder} @ {listen}"
|
||||
lines = [title, location]
|
||||
# Auth line: Paskia <url> or Password, with optional Public suffix
|
||||
if paskia_url:
|
||||
lines.append(f"Paskia: {paskia_url}")
|
||||
auth_line = f"Auth: Paskia {paskia_url}"
|
||||
else:
|
||||
auth_line = "Auth: Password"
|
||||
if public:
|
||||
auth_line += ", Public"
|
||||
lines.append(auth_line)
|
||||
if dev:
|
||||
lines.append("dev mode")
|
||||
|
||||
@@ -157,6 +165,7 @@ def _main():
|
||||
unix=opts.get("unix"),
|
||||
dev=dev,
|
||||
paskia_url=PASKIA_BACKEND_URL or None,
|
||||
public=config.config.public,
|
||||
)
|
||||
sys.stderr.write(startup_box)
|
||||
# Run the server
|
||||
|
||||
+6
-4
@@ -166,10 +166,12 @@ def subscribe(uuid, ws):
|
||||
@bp.get("config")
|
||||
async def get_config(request):
|
||||
await auth.verify(request, privileged=True)
|
||||
return json({
|
||||
"name": config.config.name,
|
||||
"public": config.config.public,
|
||||
})
|
||||
return json(
|
||||
{
|
||||
"name": config.config.name,
|
||||
"public": config.config.public,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.put("config/public")
|
||||
|
||||
+2
-2
@@ -269,7 +269,7 @@ async def verify(request, *, privileged=False):
|
||||
raise Unauthorized(
|
||||
f"Login required for {request.path}",
|
||||
"cookie",
|
||||
context={"auth": {"iframe": "/auth/restricted"}},
|
||||
context={"auth": {"iframe": "/auth/restricted/"}},
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
@@ -278,7 +278,7 @@ async def verify(request, *, privileged=False):
|
||||
bp = Blueprint("auth", url_prefix="/auth")
|
||||
|
||||
|
||||
@bp.get("/restricted")
|
||||
@bp.get("/restricted/")
|
||||
async def login_page(request):
|
||||
"""Login page that works both standalone and in paskia iframe."""
|
||||
s = session.get(request)
|
||||
|
||||
+70
-12
@@ -2,7 +2,10 @@ import asyncio
|
||||
import gc
|
||||
import io
|
||||
import mimetypes
|
||||
import threading
|
||||
import urllib.parse
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
from time import perf_counter
|
||||
from urllib.parse import unquote
|
||||
@@ -25,6 +28,49 @@ pillow_heif.register_heif_opener()
|
||||
bp = Blueprint("preview", url_prefix="/preview")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CachedPreview:
|
||||
"""Cached preview with headers and body."""
|
||||
|
||||
headers: dict[str, str]
|
||||
body: bytes
|
||||
|
||||
|
||||
class PreviewCache:
|
||||
"""Thread-safe LRU cache for preview responses."""
|
||||
|
||||
def __init__(self, capacity: int = 500):
|
||||
self.capacity = capacity
|
||||
self._cache: OrderedDict[str, CachedPreview] = OrderedDict()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, key: str) -> CachedPreview | None:
|
||||
"""Get cached preview, moving it to end (most recently used)."""
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
self._cache.move_to_end(key)
|
||||
return self._cache[key]
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: CachedPreview) -> None:
|
||||
"""Cache preview, evicting oldest if at capacity."""
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
self._cache.move_to_end(key)
|
||||
else:
|
||||
if len(self._cache) >= self.capacity:
|
||||
self._cache.popitem(last=False)
|
||||
self._cache[key] = value
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._cache)
|
||||
|
||||
|
||||
# Global preview cache instance
|
||||
_preview_cache = PreviewCache(capacity=500)
|
||||
|
||||
|
||||
@bp.on_request
|
||||
async def verify_preview(request):
|
||||
"""Verify access to preview routes."""
|
||||
@@ -55,6 +101,29 @@ async def preview(req, path):
|
||||
etag = config.derived_secret(
|
||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||
).hex()
|
||||
|
||||
if req.headers.if_none_match == etag:
|
||||
# The client has it cached, respond 304 Not Modified
|
||||
return empty(304, headers={"etag": etag})
|
||||
|
||||
# Check in-memory cache first (includes headers)
|
||||
cached = _preview_cache.get(etag)
|
||||
if cached is not None:
|
||||
logger.debug(f"Preview cache hit: {rel}")
|
||||
return raw(cached.body, headers=cached.headers)
|
||||
|
||||
if not filepath.is_file():
|
||||
raise NotFound("File not found")
|
||||
|
||||
# Generate preview
|
||||
img = await asyncio.get_event_loop().run_in_executor(
|
||||
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)
|
||||
|
||||
# Build headers and cache the full response
|
||||
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||
headers = {
|
||||
"etag": etag,
|
||||
@@ -64,19 +133,8 @@ async def preview(req, path):
|
||||
"content-type": "image/avif",
|
||||
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
||||
}
|
||||
if req.headers.if_none_match == etag:
|
||||
# The client has it cached, respond 304 Not Modified
|
||||
return empty(304, headers=headers)
|
||||
_preview_cache.set(etag, CachedPreview(headers=headers, body=img))
|
||||
|
||||
if not filepath.is_file():
|
||||
raise NotFound("File not found")
|
||||
|
||||
img = await asyncio.get_event_loop().run_in_executor(
|
||||
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)
|
||||
|
||||
|
||||
|
||||
+12
-2
@@ -48,6 +48,8 @@ async def get_client() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None or _client.is_closed:
|
||||
_client = httpx.AsyncClient(timeout=1.0)
|
||||
if "user-agent" in _client.headers:
|
||||
del _client.headers["user-agent"] # No httpx UA
|
||||
return _client
|
||||
|
||||
|
||||
@@ -171,10 +173,10 @@ async def proxy_auth_request(request):
|
||||
"upgrade",
|
||||
"proxy-authorization",
|
||||
"proxy-authenticate",
|
||||
"forwarded",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-proto",
|
||||
"forwarded",
|
||||
}
|
||||
|
||||
headers = [
|
||||
@@ -182,9 +184,17 @@ async def proxy_auth_request(request):
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in skip_headers
|
||||
]
|
||||
headers.append(("x-forwarded-for", request.client_ip))
|
||||
|
||||
# Set Forwarded headers (strip IPv6 brackets for x-forwarded-for)
|
||||
headers.append(("x-forwarded-for", request.client_ip.strip("[]")))
|
||||
headers.append(("x-forwarded-host", request.host))
|
||||
headers.append(("x-forwarded-proto", request.scheme))
|
||||
headers.append(
|
||||
(
|
||||
"forwarded",
|
||||
f"by=cista;for={request.client_ip};host={request.host};proto={request.scheme}",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
async with client.stream(
|
||||
|
||||
+62
-13
@@ -63,6 +63,7 @@ onUnmounted(watchDisconnect)
|
||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||
let vert = 0
|
||||
let timer: any = null
|
||||
|
||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
if (store.dialog) {
|
||||
if (timer) {
|
||||
@@ -76,6 +77,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
const c = fileExplorer.isCursor()
|
||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
||||
const keyup = event.type === 'keyup'
|
||||
|
||||
// Always clear repeat timer on arrow keyup, even if focus moved to input
|
||||
if (keyup && event.key.startsWith('Arrow') && timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
if (event.repeat) {
|
||||
if (
|
||||
event.key === 'ArrowUp' ||
|
||||
@@ -91,13 +99,32 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
//console.log("key pressed", event)
|
||||
/// Long if-else machina for all keys we handle here
|
||||
let arrow = ''
|
||||
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
|
||||
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
||||
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
||||
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
||||
const searchInput = inHeader && input
|
||||
const searchHasText = searchInput && (event.target as HTMLInputElement).value
|
||||
if (event.key.startsWith("Arrow")) {
|
||||
const dir = event.key.slice(5).toLowerCase()
|
||||
// In search with text: left/right move cursor, up/down navigate
|
||||
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||
return // Let browser handle cursor movement
|
||||
}
|
||||
arrow = dir
|
||||
}
|
||||
if (arrow) {
|
||||
// Arrow key handling - fall through to bottom
|
||||
}
|
||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Search also on / (UNIX style)
|
||||
else if (!input && keyup && event.key === '/') {
|
||||
// Search also on / (UNIX style) - use code to support any keyboard layout
|
||||
else if (!input && keyup && event.code === 'Slash') {
|
||||
// Record the actual character for display (varies by keyboard layout)
|
||||
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
|
||||
store.prefs.searchHotkey = event.key
|
||||
}
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Globally close search, clear errors on Escape
|
||||
@@ -143,13 +170,34 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
timer = null
|
||||
}
|
||||
let f: any
|
||||
switch (arrow) {
|
||||
case 'up': f = () => fileExplorer.up(event); break
|
||||
case 'down': f = () => fileExplorer.down(event); break
|
||||
case 'left': f = () => fileExplorer.left(event); break
|
||||
case 'right': f = () => fileExplorer.right(event); break
|
||||
// Arrow navigation - always use fileExplorer for repeatable movement
|
||||
if (arrow && !keyup) {
|
||||
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
|
||||
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
|
||||
|
||||
if (inBreadcrumb) {
|
||||
// Breadcrumb: up→header (no repeat), down→files (with repeat)
|
||||
if (arrow === 'up') { focusSearch(); f = null }
|
||||
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null }
|
||||
} else if (inHeader) {
|
||||
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
|
||||
const items = Array.from(document.querySelectorAll('.headermain button:not([tabindex=\"-1\"]), .headermain input[type=\"search\"], .headermain [tabindex=\"0\"]')) as HTMLElement[]
|
||||
const idx = items.indexOf(document.activeElement as HTMLElement)
|
||||
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null }
|
||||
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null }
|
||||
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
|
||||
else if (arrow === 'down') { focusBreadcrumb(); f = null }
|
||||
} else {
|
||||
// File explorer: normal navigation with repeat
|
||||
switch (arrow) {
|
||||
case 'up': f = () => fileExplorer.up(event); break
|
||||
case 'down': f = () => fileExplorer.down(event); break
|
||||
case 'left': f = () => fileExplorer.left(event); break
|
||||
case 'right': f = () => fileExplorer.right(event); break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (f && !keyup) {
|
||||
if (f) {
|
||||
// Initial move, then t0 delay until repeats at tr intervals
|
||||
const t0 = 200, tr = event.altKey ? 20 : 100
|
||||
f()
|
||||
@@ -157,12 +205,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', globalShortcutHandler)
|
||||
window.addEventListener('keyup', globalShortcutHandler)
|
||||
// Use capture phase to handle events before they reach target elements
|
||||
window.addEventListener('keydown', globalShortcutHandler, true)
|
||||
window.addEventListener('keyup', globalShortcutHandler, true)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', globalShortcutHandler)
|
||||
window.removeEventListener('keyup', globalShortcutHandler)
|
||||
window.removeEventListener('keydown', globalShortcutHandler, true)
|
||||
window.removeEventListener('keyup', globalShortcutHandler, true)
|
||||
})
|
||||
export type { Path }
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="disk-space-container" ref="containerRef">
|
||||
<div class="disk-space-container" ref="containerRef" tabindex="0" @keydown.enter="handleClick" @keydown.space.prevent="handleClick">
|
||||
<div
|
||||
ref="widgetRef"
|
||||
class="disk-space-widget"
|
||||
@@ -157,8 +157,8 @@ const freeColor = computed(() => {
|
||||
if (!s.disk) return '#6c6'
|
||||
const freePct = s.free / s.disk
|
||||
if (freePct > 0.25) return '#5b5'
|
||||
if (freePct > 0.10) return '#db3'
|
||||
return '#d44'
|
||||
if (freePct > 0.10) return '#ff0'
|
||||
return '#f00'
|
||||
})
|
||||
|
||||
const PIE_RADIUS = 55
|
||||
@@ -352,6 +352,11 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.disk-space-container:focus .disk-space-widget:not(.expanded) {
|
||||
filter: brightness(1);
|
||||
}
|
||||
|
||||
.disk-space-widget {
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
@@ -135,6 +135,17 @@ defineExpose({
|
||||
isCursor() {
|
||||
return store.cursor && editing.value === null
|
||||
},
|
||||
focusFirst() {
|
||||
const docs = props.documents
|
||||
if (docs.length > 0) {
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
cursorRename() {
|
||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||
},
|
||||
@@ -150,7 +161,12 @@ defineExpose({
|
||||
},
|
||||
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||
left(ev: KeyboardEvent) { router.back() },
|
||||
left(ev: KeyboardEvent) {
|
||||
// Only go back if we're in a subfolder (not at root)
|
||||
if (props.path.length > 0) {
|
||||
router.back()
|
||||
}
|
||||
},
|
||||
right(ev: KeyboardEvent) {
|
||||
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
||||
if (a) a.click()
|
||||
@@ -190,9 +206,17 @@ defineExpose({
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
if (moveto === N) focusBreadcrumb()
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
@@ -210,7 +234,7 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && store.cursor) {
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { connect, controlUrl } from '@/repositories/WS'
|
||||
@@ -82,6 +82,17 @@ defineExpose({
|
||||
isCursor() {
|
||||
return store.cursor && editing.value === null
|
||||
},
|
||||
focusFirst() {
|
||||
const docs = props.documents
|
||||
if (docs.length > 0) {
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
cursorRename() {
|
||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||
},
|
||||
@@ -144,9 +155,17 @@ defineExpose({
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
if (moveto === N) focusBreadcrumb()
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
@@ -162,7 +181,7 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && store.cursor) {
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="smallgap"></div>
|
||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||
<div class="search-group">
|
||||
<SvgButton name="find" @click="focusSearch" tooltip="Search" />
|
||||
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
@@ -17,7 +17,7 @@
|
||||
@input="updateSearch"
|
||||
@keydown.escape="clearSearch"
|
||||
/>
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||
</div>
|
||||
<div class="spacer smallgap"></div>
|
||||
<DiskSpace v-if="store.space.disk" />
|
||||
@@ -115,7 +115,7 @@ const settingsMenu = (e: Event) => {
|
||||
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||
items.push({ label: '🔐 Login', onClick: async () => {
|
||||
try {
|
||||
await showAuthIframe('/auth/restricted#theme=light')
|
||||
await showAuthIframe('/auth/restricted/#theme=light')
|
||||
resumeWatching()
|
||||
} catch (e) {
|
||||
console.log('Login cancelled')
|
||||
@@ -159,6 +159,9 @@ defineExpose({
|
||||
.search-group:focus-within {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.search-group:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--accent-color, #f80);
|
||||
}
|
||||
.search-group:hover :deep(button.action-button),
|
||||
.search-group:focus-within :deep(button.action-button) {
|
||||
transform: scale(1.1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<button
|
||||
class="action-button"
|
||||
:tabindex="tabindex"
|
||||
@mouseenter="tooltip?.startHover"
|
||||
@mousemove="tooltip?.updatePosition"
|
||||
@mouseleave="tooltip?.endHover"
|
||||
@@ -19,6 +20,7 @@ import CursorTooltip from './CursorTooltip.vue'
|
||||
const props = defineProps<{
|
||||
name: IconName
|
||||
tooltip?: string
|
||||
tabindex?: string | number
|
||||
}>()
|
||||
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
@@ -87,6 +87,7 @@ export const useMainStore = defineStore('main', {
|
||||
gallery: false,
|
||||
sortListing: '' as SortOrder,
|
||||
sortFiltered: '' as SortOrder,
|
||||
searchHotkey: '/', // Character shown for search hotkey (Slash key)
|
||||
},
|
||||
user: {
|
||||
username: '' as string,
|
||||
@@ -221,6 +222,7 @@ export const useMainStore = defineStore('main', {
|
||||
name: doc.name,
|
||||
key: doc.key,
|
||||
size: doc.size,
|
||||
allocated: doc.allocated,
|
||||
mtime: doc.mtime,
|
||||
dir: doc.dir,
|
||||
}))
|
||||
|
||||
@@ -6,6 +6,7 @@ interface DocData {
|
||||
name: string
|
||||
key: string
|
||||
size: number
|
||||
allocated: number
|
||||
mtime: number
|
||||
dir: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user