Enhance file browser API and dialog components
This commit is contained in:
parent
2695bb40fc
commit
28e3062bef
4 changed files with 250 additions and 65 deletions
|
|
@ -135,7 +135,24 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
|
|||
req_path: str = request.match_info.get("path")
|
||||
req_path: str = "/" if not req_path else unquote_plus(req_path)
|
||||
|
||||
test: Path = Path(config.download_path).joinpath(req_path)
|
||||
# Normalize requested path to always be inside download root.
|
||||
raw_req: str = (req_path or "").strip()
|
||||
root_dir: Path = Path(config.download_path).resolve()
|
||||
if raw_req in ("", "/"):
|
||||
test: Path = root_dir
|
||||
rel_for_listing = "/"
|
||||
else:
|
||||
# Strip leading slash so joinpath doesn't ignore the base path.
|
||||
test = root_dir.joinpath(raw_req.lstrip("/")).resolve(strict=False)
|
||||
rel_for_listing = raw_req.lstrip("/")
|
||||
|
||||
try:
|
||||
test.relative_to(root_dir)
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||
)
|
||||
|
||||
if not test.exists():
|
||||
return web.json_response(
|
||||
data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||
|
|
@ -149,8 +166,8 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
|
|||
try:
|
||||
return web.json_response(
|
||||
data={
|
||||
"path": req_path,
|
||||
"contents": get_files(base_path=Path(config.download_path), dir=req_path),
|
||||
"path": rel_for_listing,
|
||||
"contents": get_files(base_path=root_dir, dir=rel_for_listing),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
|
|
@ -235,13 +252,14 @@ async def path_actions(request: Request, config: Config) -> Response:
|
|||
rel: str = str(op_path).strip("/")
|
||||
except Exception:
|
||||
rel = str(op_path)
|
||||
entry: dict = {
|
||||
"path": rel if rel else "/",
|
||||
"status": ok,
|
||||
"error": error,
|
||||
}
|
||||
if not rel or rel == ".":
|
||||
rel = "/"
|
||||
|
||||
entry: dict = {"path": rel, "status": ok, "error": error}
|
||||
|
||||
if action:
|
||||
entry["action"] = action
|
||||
|
||||
if extra:
|
||||
norm_extra: dict = {}
|
||||
for k, v in extra.items():
|
||||
|
|
@ -253,6 +271,7 @@ async def path_actions(request: Request, config: Config) -> Response:
|
|||
else:
|
||||
norm_extra[k] = v
|
||||
entry.update(norm_extra)
|
||||
|
||||
operations_status.append(entry)
|
||||
|
||||
# perform each action
|
||||
|
|
@ -289,7 +308,7 @@ async def path_actions(request: Request, config: Config) -> Response:
|
|||
continue
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
record(req_path, ok=False, error=str(e), action=action)
|
||||
record(req_path, ok=False, error=str(e), action=action, extra={"item": params})
|
||||
continue
|
||||
|
||||
if "directory" != action:
|
||||
|
|
@ -301,6 +320,38 @@ async def path_actions(request: Request, config: Config) -> Response:
|
|||
record(path, ok=False, error="Path outside download root.", action=action)
|
||||
continue
|
||||
|
||||
if "directory" == action:
|
||||
new_dir = params.get("new_dir").lstrip("/").strip()
|
||||
if not new_dir:
|
||||
record(path, ok=False, error="New directory name is required.", action=action)
|
||||
continue
|
||||
|
||||
new_path = path.joinpath(*new_dir.split("/"))
|
||||
try:
|
||||
new_path = new_path.resolve(strict=False)
|
||||
except Exception:
|
||||
record(path, ok=False, error="Invalid directory path.", action=action, extra={"new_dir": new_dir})
|
||||
continue
|
||||
|
||||
root_real = Path(config.download_path).resolve()
|
||||
if not new_path.is_relative_to(root_real):
|
||||
record(path, ok=False, error="Destination outside root.", action=action, extra={"new_dir": new_path})
|
||||
continue
|
||||
|
||||
if new_path.exists():
|
||||
dst = new_path.relative_to(config.download_path)
|
||||
record(path, ok=False, error="Directory already exists.", action=action, extra={"new_dir": dst})
|
||||
continue
|
||||
|
||||
try:
|
||||
new_path.mkdir(parents=True, exist_ok=True)
|
||||
record(path, ok=True, action=action, extra={"new_dir": new_path.relative_to(config.download_path)})
|
||||
LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'")
|
||||
except OSError as e:
|
||||
LOG.exception(e)
|
||||
record(path, ok=False, error=str(e), action=action, extra={"item": params})
|
||||
continue
|
||||
|
||||
if "rename" == action:
|
||||
new_name: str = params.get("new_name")
|
||||
if not new_name:
|
||||
|
|
@ -315,16 +366,16 @@ async def path_actions(request: Request, config: Config) -> Response:
|
|||
continue
|
||||
|
||||
try:
|
||||
path.rename(new_path)
|
||||
renamed = path.rename(new_path)
|
||||
LOG.info(
|
||||
f"Renamed '{path.relative_to(config.download_path)}' to '{new_path.relative_to(config.download_path)}'"
|
||||
f"Renamed '{path.relative_to(config.download_path)}' to '{renamed.relative_to(config.download_path)}'"
|
||||
)
|
||||
except OSError as e:
|
||||
LOG.exception(e)
|
||||
record(path, ok=False, error=str(e), action=action, extra={"new_path": new_path})
|
||||
record(path, ok=False, error=str(e), action=action, extra={"item": params})
|
||||
continue
|
||||
else:
|
||||
record(path, ok=True, action=action, extra={"new_path": new_path})
|
||||
record(path, ok=True, action=action, extra={"new_path": renamed})
|
||||
|
||||
if "delete" == action:
|
||||
try:
|
||||
|
|
@ -340,7 +391,7 @@ async def path_actions(request: Request, config: Config) -> Response:
|
|||
LOG.info(f"Deleted '{path.relative_to(config.download_path)}'")
|
||||
except OSError as e:
|
||||
LOG.exception(e)
|
||||
record(path, ok=False, error=str(e), action=action)
|
||||
record(path, ok=False, error=str(e), action=action, extra={"item": params})
|
||||
continue
|
||||
else:
|
||||
record(path, ok=True, action=action, extra={"deleted": True})
|
||||
|
|
@ -348,46 +399,54 @@ async def path_actions(request: Request, config: Config) -> Response:
|
|||
if "move" == action:
|
||||
new_path = params.get("new_path")
|
||||
if not new_path:
|
||||
record(path, ok=False, error="New path is required for move action.", action=action)
|
||||
record(path, ok=False, error="New path is required for move.", action=action, extra={"item": params})
|
||||
continue
|
||||
|
||||
new_path = Path(config.download_path).joinpath(unquote_plus(new_path))
|
||||
if not new_path.exists() or not new_path.is_dir():
|
||||
record(path, ok=False, error="Destination path is invalid.", action=action, extra={"move_to": new_path})
|
||||
continue
|
||||
raw_new: str = unquote_plus(str(new_path)).strip()
|
||||
target_dir = (
|
||||
Path(config.download_path)
|
||||
if not raw_new or raw_new in ("/", ".")
|
||||
else Path(config.download_path).joinpath(raw_new.lstrip("/"))
|
||||
)
|
||||
|
||||
try:
|
||||
dest = new_path.joinpath(path.name)
|
||||
path.rename(dest)
|
||||
except OSError as e:
|
||||
LOG.exception(e)
|
||||
record(path, ok=False, error=str(e), action=action, extra={"moved_to": dest})
|
||||
continue
|
||||
else:
|
||||
record(path, ok=True, action=action, extra={"moved_to": dest})
|
||||
|
||||
if "directory" == action:
|
||||
new_dir = params.get("new_dir").lstrip("/").strip()
|
||||
if not new_dir:
|
||||
record(path, ok=False, error="New directory name is required for directory action.", action=action)
|
||||
continue
|
||||
|
||||
new_path = path.joinpath(*new_dir.split("/"))
|
||||
if new_path.exists():
|
||||
target_dir = target_dir.resolve()
|
||||
except Exception:
|
||||
record(
|
||||
new_path, ok=False, error="Directory already exists.", action=action, extra={"created": new_path}
|
||||
path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir}
|
||||
)
|
||||
continue
|
||||
|
||||
root_real = Path(config.download_path).resolve()
|
||||
if not target_dir.exists() or not target_dir.is_dir():
|
||||
record(
|
||||
path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir}
|
||||
)
|
||||
continue
|
||||
|
||||
if not target_dir.is_relative_to(root_real):
|
||||
record(
|
||||
path,
|
||||
ok=False,
|
||||
error="Destination outside download root.",
|
||||
action=action,
|
||||
extra={"new_path": target_dir},
|
||||
)
|
||||
continue
|
||||
|
||||
if path.parent == target_dir:
|
||||
record(path, ok=False, error="Source and destination are the same.", action=action)
|
||||
continue
|
||||
|
||||
try:
|
||||
new_path.mkdir(parents=True, exist_ok=True)
|
||||
LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'")
|
||||
dest = target_dir.joinpath(path.name)
|
||||
path.rename(dest)
|
||||
except OSError as e:
|
||||
LOG.exception(e)
|
||||
record(new_path, ok=False, error=str(e), action=action, extra={"created": new_path})
|
||||
record(path, ok=False, error=str(e), action=action, extra={"item": params})
|
||||
continue
|
||||
else:
|
||||
record(new_path, ok=True, action=action, extra={"created": new_path})
|
||||
record(path, ok=True, action=action, extra={"new_path": dest})
|
||||
|
||||
return web.json_response(data=operations_status, status=web.HTTPOk.status_code)
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@
|
|||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="'confirm' === state.current?.type && (state.current?.opts as ConfirmOptions)?.rawHTML"
|
||||
class="content" v-html="(state.current?.opts as ConfirmOptions)?.rawHTML" />
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot p-4 is-justify-content-flex-end">
|
||||
|
|
@ -71,9 +73,8 @@
|
|||
<template v-else-if="state.current?.type === 'confirm' || state.current?.type === 'prompt'">
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button" @click="onEnter"
|
||||
:class="state.current?.opts.confirmColor ?? 'is-primary'"
|
||||
:disabled="localInput === (state.current?.opts as PromptOptions)?.initial">
|
||||
<button class="button" @click="onEnter" :class="state.current?.opts.confirmColor ?? 'is-primary'"
|
||||
:disabled="localInput === (state.current?.opts as PromptOptions)?.initial">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-check" /></span>
|
||||
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
|
||||
|
|
@ -99,7 +100,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import { useDialog, type PromptOptions } from '~/composables/useDialog'
|
||||
import { useDialog, type ConfirmOptions, type PromptOptions } from '~/composables/useDialog'
|
||||
|
||||
const { state, confirm, cancel } = useDialog()
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ export type ConfirmOptions = BaseOptions & {
|
|||
* Text for the confirm button
|
||||
*/
|
||||
cancelText?: string
|
||||
/**
|
||||
* Raw HTML content to include in the dialog message.
|
||||
*/
|
||||
rawHTML?: string
|
||||
}
|
||||
|
||||
export type AlertOptions = BaseOptions & {}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
</div>
|
||||
|
||||
<div class="control">
|
||||
<button class="button is-danger is-light" @click="toggleFilter" v-tooltip.bottom="'Filter content'">
|
||||
<button class="button is-danger is-light" @click="toggleFilter">
|
||||
<span class="icon"><i class="fas fa-filter" /></span>
|
||||
<span v-if="!isMobile">Filter</span>
|
||||
</button>
|
||||
|
|
@ -35,8 +35,7 @@
|
|||
|
||||
<p class="control">
|
||||
<button class="button is-info is-light" @click="createDirectory(path)"
|
||||
:class="{ 'is-loading': isLoading }" :disabled="!socket.isConnected || isLoading"
|
||||
v-tooltip.bottom="'Create new directory'" v-if="config.app.browser_control_enabled">
|
||||
v-if="config.app.browser_control_enabled">
|
||||
<span class="icon"><i class="fas fa-folder-plus" /></span>
|
||||
<span v-if="!isMobile">New Folder</span>
|
||||
</button>
|
||||
|
|
@ -56,21 +55,22 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline"
|
||||
v-if="items && items.length > 0 && selectedElms.length > 0 && config.app.browser_control_enabled">
|
||||
<div class="columns is-multiline" v-if="config.app.browser_control_enabled">
|
||||
<div class="column is-half-mobile">
|
||||
<button type="button" class="button is-fullwidth is-danger" @click="confirm_delete">
|
||||
<button type="button" class="button is-fullwidth is-danger" @click="deleteSelected"
|
||||
:disabled="selectedElms.length < 1 || isLoading || items.length < 1">
|
||||
<span class="icon-text is-block">
|
||||
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
|
||||
<span>Delete selected</span>
|
||||
<span>Delete {{ selectedElms.length > 0 ? selectedElms.length : '' }} items</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-half-mobile">
|
||||
<button type="button" class="button is-fullwidth is-link">
|
||||
<button type="button" class="button is-fullwidth is-link" @click="moveSelected"
|
||||
:disabled="selectedElms.length < 1 || isLoading || items.length < 1">
|
||||
<span class="icon-text is-block">
|
||||
<span class="icon"><i class="fa-solid fa-arrows-alt" /></span>
|
||||
<span>Move selected</span>
|
||||
<span>Move {{ selectedElms.length > 0 ? selectedElms.length : '' }} items</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -417,6 +417,10 @@ const reloadContent = async (dir: string = '/', fromMounted: boolean = false): P
|
|||
}
|
||||
|
||||
items.value = []
|
||||
search.value = ''
|
||||
selectedElms.value = []
|
||||
show_filter.value = false
|
||||
masterSelectAll.value = false
|
||||
|
||||
const data = await response.json() as FileBrowserResponse
|
||||
|
||||
|
|
@ -554,8 +558,15 @@ const createDirectory = async (dir: string): Promise<void> => {
|
|||
return
|
||||
}
|
||||
|
||||
const newDir = prompt('Enter new directory name:', '')
|
||||
if (!newDir) {
|
||||
const { status, value: newDir } = await dialog.promptDialog({
|
||||
title: 'Create New Directory',
|
||||
message: `Enter name for new directory in '${dir || '/'}':`,
|
||||
initial: '',
|
||||
confirmText: 'Create',
|
||||
cancelText: 'Cancel',
|
||||
})
|
||||
|
||||
if (true !== status || !newDir) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -564,7 +575,7 @@ const createDirectory = async (dir: string): Promise<void> => {
|
|||
return
|
||||
}
|
||||
|
||||
await actionRequest({ path: dir } as FileItem, 'directory', { new_dir: new_dir }, (item, action, data) => {
|
||||
await actionRequest({ path: dir || '/' } as FileItem, 'directory', { new_dir: new_dir }, (item, action, data) => {
|
||||
reloadContent(path.value, true)
|
||||
toast.success(`Successfully created '${new_dir}'.`)
|
||||
})
|
||||
|
|
@ -626,7 +637,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
|
|||
const { status, value: newPath } = await dialog.promptDialog({
|
||||
title: 'Move Item',
|
||||
message: `Enter new path for '${item.name}':`,
|
||||
initial: item.path.replace(/[^/]+$/, ''),
|
||||
initial: item.path.replace(/[^/]+$/, '') || '/',
|
||||
confirmText: 'Move',
|
||||
cancelText: 'Cancel',
|
||||
})
|
||||
|
|
@ -635,7 +646,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
|
|||
return
|
||||
}
|
||||
|
||||
let new_path = sTrim(newPath, '/')
|
||||
let new_path = sTrim(newPath, '/') || '/'
|
||||
if (!new_path || new_path === item.path) {
|
||||
return
|
||||
}
|
||||
|
|
@ -702,25 +713,135 @@ const actionRequest = async (
|
|||
}
|
||||
}
|
||||
|
||||
const confirm_delete = () => {
|
||||
const massAction = async (items: Array<{ path: string, action: string }>, cb: (item: any) => void): Promise<any> => {
|
||||
if (!config.app.browser_control_enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!items || items.length < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(`/api/file/actions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(items),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
toast.error(`Failed to perform action: ${error.error || 'Unknown error'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const json = await response.json() as Array<{ path: string, status: boolean, error?: string }>
|
||||
json.forEach(i => {
|
||||
if (true !== i.status) {
|
||||
toast.error(`Failed to perform action on '${i.path}': ${i.error || 'Unknown error'}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (cb && typeof cb === 'function') {
|
||||
cb(i)
|
||||
}
|
||||
});
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
toast.error(`Failed to perform action: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSelected = async () => {
|
||||
if (selectedElms.value.length < 1) {
|
||||
toast.error('No items selected.')
|
||||
return
|
||||
}
|
||||
dialog_confirm.value.visible = true
|
||||
dialog_confirm.value.title = 'Delete Confirmation'
|
||||
|
||||
dialog_confirm.value.html_message = `Delete the following items?<ul>` + selectedElms.value.map(p => {
|
||||
const rawHTML = `<ul>` + selectedElms.value.map(p => {
|
||||
const item = items.value.find(i => i.path === p)
|
||||
if (!item) {
|
||||
return ''
|
||||
}
|
||||
const color = item.type === 'dir' ? 'has-text-primary is-bold' : ''
|
||||
const color = 'dir' === item.type ? 'has-text-danger is-bold' : ''
|
||||
|
||||
return `<li><span class="icon"><i class="fa-solid ${setIcon(item)}"></i></span> <span class="${color}">${item.name}</span></li>`
|
||||
}).join('') + `</ul>`
|
||||
|
||||
// dialog_confirm.value.confirm = async () => await deleteSelected()
|
||||
|
||||
const { status } = await dialog.confirmDialog({
|
||||
title: 'Delete Confirmation',
|
||||
message: `Delete the following items?`,
|
||||
rawHTML: rawHTML,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
confirmColor: 'is-danger',
|
||||
})
|
||||
|
||||
if (true !== status) {
|
||||
selectedElms.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const actions = [] as Array<{ action: string, path: string }>
|
||||
selectedElms.value.forEach(p => {
|
||||
const item = items.value.find(i => i.path === p)
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
actions.push({ path: item.path, action: 'delete' })
|
||||
})
|
||||
|
||||
await massAction(actions, i => {
|
||||
const item = items.value.find(it => it.path === i.path)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
items.value = items.value.filter(it => it.path !== i.path)
|
||||
toast.warning(`Deleted '${item.name}'.`)
|
||||
})
|
||||
}
|
||||
|
||||
const moveSelected = async () => {
|
||||
if (selectedElms.value.length < 1) {
|
||||
toast.error('No items selected.')
|
||||
return
|
||||
}
|
||||
|
||||
const { status, value: newPath } = await dialog.promptDialog({
|
||||
title: 'Move Items',
|
||||
message: `Enter new path for selected items:`,
|
||||
initial: path.value || '/',
|
||||
confirmText: 'Move',
|
||||
confirmColor: 'is-danger',
|
||||
cancelText: 'Cancel',
|
||||
})
|
||||
|
||||
if (true !== status || !newPath || newPath === path.value) {
|
||||
selectedElms.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const actions = [] as Array<{ action: string, path: string, new_path: string }>
|
||||
selectedElms.value.forEach(p => {
|
||||
const item = items.value.find(i => i.path === p)
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
actions.push({
|
||||
path: item.path,
|
||||
action: 'move',
|
||||
new_path: sTrim(newPath, '/') || '/',
|
||||
})
|
||||
})
|
||||
|
||||
await massAction(actions, i => {
|
||||
items.value = items.value.filter(it => it.path !== i.path)
|
||||
toast.success(`Moved '${i.path}' to '${sTrim(newPath, '/')}'.`)
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue