Add drag and drop to file manager
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { fs_encode_path } from "lib/FilesystemAPI.svelte";
|
||||
import { path_link, type FSNavigator } from "./FSNavigator";
|
||||
import { move_target, path_link, type FSNavigator } from "./FSNavigator";
|
||||
import FileMenu from "./filemanager/FileMenu.svelte";
|
||||
import EditWindow from "./edit_window/EditWindow.svelte";
|
||||
import { onMount } from "svelte";
|
||||
@@ -29,6 +29,7 @@ onMount(() => {
|
||||
href={"/d"+fs_encode_path(node.path)}
|
||||
class="breadcrumb button flat"
|
||||
use:path_link={{nav: nav, node: node}}
|
||||
use:move_target={{nav, node: () => node}}
|
||||
oncontextmenu={e => file_menu.open(node, e.target, e)}
|
||||
>
|
||||
{#if node.abuse_type !== undefined}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { loading_finish, loading_start } from "lib/Loading";
|
||||
import { fs_get_node, fs_encode_path, fs_split_path, fs_path_from_json } from "lib/FilesystemAPI.svelte";
|
||||
import { loading_finish, loading_run, loading_start } from "lib/Loading";
|
||||
import { fs_get_node, fs_encode_path, fs_split_path, fs_path_from_json, fs_rename } from "lib/FilesystemAPI.svelte";
|
||||
import type { FSNode, FSPath, FSPermissions, FSContext } from "lib/FilesystemAPI.svelte";
|
||||
|
||||
export class FSNavigator {
|
||||
@@ -251,6 +251,84 @@ export class FSNavigator {
|
||||
// the reactivity
|
||||
this.notify_subscribers()
|
||||
}
|
||||
|
||||
// The selection is kept by path, so it survives reloads and navigating to
|
||||
// other directories. The directory views look each entry up in it
|
||||
selecting = false
|
||||
selection = new Map<string, FSNode>()
|
||||
last_selected = ""
|
||||
|
||||
// Leaving selection mode clears the selection
|
||||
set_selecting = (on: boolean) => {
|
||||
this.selecting = on
|
||||
if (!on) {
|
||||
this.selection.clear()
|
||||
}
|
||||
this.notify_subscribers()
|
||||
}
|
||||
|
||||
// Toggles a node. With range, everything between it and the node which was
|
||||
// toggled before it is selected. Hidden nodes are left out of the range when
|
||||
// the view doesn't show them
|
||||
select = (node: FSNode, range = false, hidden = true) => {
|
||||
const from = range ? this.children.findIndex(c => c.path === this.last_selected) : -1
|
||||
if (from !== -1) {
|
||||
const to = this.children.indexOf(node)
|
||||
for (let i = Math.min(from, to); i <= Math.max(from, to); i++) {
|
||||
if (hidden || !this.children[i].is_hidden()) {
|
||||
this.selection.set(this.children[i].path, this.children[i])
|
||||
}
|
||||
}
|
||||
} else if (!this.selection.delete(node.path)) {
|
||||
this.selection.set(node.path, node)
|
||||
}
|
||||
this.last_selected = node.path
|
||||
this.set_selecting(true)
|
||||
}
|
||||
|
||||
// A directory can't be moved into itself, and there has to be at least one
|
||||
// node which is not in dir already
|
||||
can_move = (dir: string) => {
|
||||
let moves = false
|
||||
for (const node of this.selection.values()) {
|
||||
if ((dir + "/").startsWith(node.path + "/")) {
|
||||
return false
|
||||
}
|
||||
moves ||= node.path !== dir + "/" + node.name
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
move_selection = async (dir: string) => {
|
||||
try {
|
||||
await loading_run(() => Promise.all(
|
||||
[...this.selection.values()]
|
||||
.filter(node => node.path !== dir + "/" + node.name)
|
||||
.map(node => fs_rename(node.path, dir + "/" + node.name))
|
||||
))
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
alert("Move failed: " + err.message + " (" + err.value + ")")
|
||||
}
|
||||
this.set_selecting(false)
|
||||
await this.reload()
|
||||
}
|
||||
|
||||
// Dragging a node drags the whole selection. The drag itself only carries a
|
||||
// marker, as the selection outlives the element which is being dragged when
|
||||
// the user navigates during the drag
|
||||
drag_start = (e: DragEvent, node: FSNode) => {
|
||||
e.dataTransfer.setData(drag_type, node.path)
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
|
||||
if (!this.selecting) {
|
||||
// The drag started the selection, so it ends it as well
|
||||
e.target.addEventListener("dragend", () => this.set_selecting(false), { once: true })
|
||||
}
|
||||
if (!this.selection.has(node.path)) {
|
||||
this.select(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sort_children = (children: FSNode[], field: string, asc: boolean) => {
|
||||
@@ -287,7 +365,55 @@ const sort_children = (children: FSNode[], field: string, asc: boolean) => {
|
||||
|
||||
export let global_navigator = new FSNavigator(true)
|
||||
|
||||
const drag_type = "application/x-nova-selection"
|
||||
const is_drag = (e: DragEvent) => e.dataTransfer.types.includes(drag_type)
|
||||
|
||||
// Moves the selection into a directory when it's dropped on the element. The
|
||||
// node is a function because an action only gets its parameters once. Bookmarks
|
||||
// have no type, the server refuses the move if one turns out to be a file
|
||||
export const move_target = (
|
||||
el: HTMLElement,
|
||||
data: { nav: FSNavigator, node: () => { path: string, type?: string } },
|
||||
) => {
|
||||
const accepts = (e: DragEvent) => {
|
||||
if (!is_drag(e) || data.node().type === "file" || !data.nav.can_move(data.node().path)) {
|
||||
return false
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return true
|
||||
}
|
||||
el.addEventListener("dragover", e => {
|
||||
if (accepts(e)) {
|
||||
el.style.boxShadow = "var(--accent_color) 0 0 0 2px inset"
|
||||
}
|
||||
})
|
||||
el.addEventListener("dragleave", () => el.style.boxShadow = "")
|
||||
el.addEventListener("drop", e => {
|
||||
el.style.boxShadow = ""
|
||||
if (accepts(e)) {
|
||||
data.nav.move_selection(data.node().path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Clicks the element when the selection is held over it, so the navigation
|
||||
// buttons work during a drag
|
||||
export const drag_click = (el: HTMLElement) => {
|
||||
let timer: number
|
||||
el.addEventListener("dragenter", e => {
|
||||
if (is_drag(e)) {
|
||||
clearTimeout(timer)
|
||||
timer = window.setTimeout(() => el.click(), 500)
|
||||
}
|
||||
})
|
||||
el.addEventListener("dragleave", e => {
|
||||
// Moving onto a child of the element is not leaving it
|
||||
if (!el.contains(e.relatedTarget as Node)) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const path_link = (node: HTMLAnchorElement, data: { nav: FSNavigator, node: FSNode }) => {
|
||||
// Set url
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte"
|
||||
import type { FSNavigator } from "filesystem/FSNavigator";
|
||||
import { move_target, type FSNavigator } from "filesystem/FSNavigator";
|
||||
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
||||
|
||||
let {
|
||||
@@ -24,11 +24,13 @@ let {
|
||||
href={"/d"+fs_encode_path(child.path)}
|
||||
onclick={e => file_event(FileAction.Click, index, e)}
|
||||
oncontextmenu={e => file_event(FileAction.Context, index, e)}
|
||||
ondragstart={e => file_event(FileAction.Drag, index, e)}
|
||||
use:move_target={{nav, node: () => child}}
|
||||
class="node"
|
||||
class:node_selected={child.fm_selected}
|
||||
class:node_selected={$nav.selection.has(child.path)}
|
||||
class:hidden={child.name.startsWith(".") && !show_hidden}
|
||||
>
|
||||
<img src={fs_node_icon(child, 64, 64)} class="node_icon" class:large_icons loading="lazy" alt="icon"/>
|
||||
<img src={fs_node_icon(child, 64, 64)} class="node_icon" class:large_icons loading="lazy" alt="icon" draggable="false"/>
|
||||
<div class="node_name">
|
||||
{child.name}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ const scroll_positions = new Map<string, number>()
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { fs_rename, fs_trash, FSNode } from "lib/FilesystemAPI.svelte"
|
||||
import { fs_trash } from "lib/FilesystemAPI.svelte"
|
||||
import CreateDirectory from "./CreateDirectory.svelte"
|
||||
import ListView from "./ListView.svelte"
|
||||
import GalleryView from "./GalleryView.svelte"
|
||||
@@ -13,14 +13,13 @@ import CompactView from "./CompactView.svelte"
|
||||
import Button from "layout/Button.svelte";
|
||||
import { formatDate } from "util/Formatting";
|
||||
import { drop_target } from "lib/DropTarget"
|
||||
import type { FSNavigator } from "filesystem/FSNavigator";
|
||||
import { drag_click, move_target, type FSNavigator } from "filesystem/FSNavigator";
|
||||
import FsUploadWidget from "filesystem/upload_widget/FSUploadWidget.svelte";
|
||||
import EditWindow from "filesystem/edit_window/EditWindow.svelte";
|
||||
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
||||
import FileMenu from "./FileMenu.svelte";
|
||||
import { loading_finish, loading_start } from "lib/Loading";
|
||||
import SearchBar from "../SearchBar.svelte";
|
||||
import type { GenericResponse } from "lib/NovaAPI";
|
||||
import { onMount } from "svelte";
|
||||
import { toolbar_store } from "wrap/HeaderStore";
|
||||
|
||||
@@ -42,7 +41,9 @@ let {
|
||||
|
||||
let large_icons = $state(localStorage.getItem("large_icons") === "true")
|
||||
let uploader!: FsUploadWidget
|
||||
let mode = $state("viewing")
|
||||
// In selection mode a click selects. Moving is the part of selection mode where
|
||||
// the user browses to the directory which the selection has to go to
|
||||
let moving = $state(false)
|
||||
let creating_dir = $state(false)
|
||||
let show_hidden = $state(false)
|
||||
let file_menu!: FileMenu
|
||||
@@ -56,6 +57,15 @@ export const upload = (files: File[]) => {
|
||||
|
||||
// Navigation functions
|
||||
const file_event: FileActionHandler = (action: FileAction, index: number, orig: Event) => {
|
||||
const node = nav.children[index]
|
||||
if (action === FileAction.Drag) {
|
||||
// Without write access it stays a normal drag of the link
|
||||
if (nav.permissions.write) {
|
||||
nav.drag_start(orig as DragEvent, node)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
orig.preventDefault()
|
||||
orig.stopPropagation()
|
||||
|
||||
@@ -63,42 +73,34 @@ const file_event: FileActionHandler = (action: FileAction, index: number, orig:
|
||||
case FileAction.Click:
|
||||
creating_dir = false
|
||||
|
||||
if (mode === "viewing") {
|
||||
nav.navigate(nav.children[index].path, true)
|
||||
} else if (mode === "moving") {
|
||||
if (!nav.selecting) {
|
||||
nav.navigate(node.path, true)
|
||||
} else if (!moving) {
|
||||
nav.select(node, (orig as MouseEvent).shiftKey, show_hidden)
|
||||
} else if (node.type === "dir" && !nav.selection.has(node.path)) {
|
||||
// If we are moving files we can only enter directories, and only if
|
||||
// they're not selected. That last requirement prevents people from
|
||||
// moving a directory into itself
|
||||
if (nav.children[index].type === "dir" && !nav.children[index].fm_selected) {
|
||||
nav.navigate(nav.children[index].path, true)
|
||||
}
|
||||
} else if (mode === "selecting") {
|
||||
select_node(index)
|
||||
nav.navigate(node.path, true)
|
||||
}
|
||||
break
|
||||
case FileAction.Context:
|
||||
// If this is a touch event we will select the item
|
||||
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
|
||||
select_node(index)
|
||||
nav.select(node)
|
||||
} else {
|
||||
file_menu.open(nav.children[index], orig.target, orig)
|
||||
file_menu.open(node, orig.target, orig)
|
||||
}
|
||||
break
|
||||
case FileAction.Edit:
|
||||
edit_window.edit(nav.children[index], false, "file")
|
||||
break
|
||||
case FileAction.Share:
|
||||
creating_dir = false
|
||||
edit_window.edit(nav.children[index], false, "share")
|
||||
edit_window.edit(node, false, "share")
|
||||
break
|
||||
case FileAction.Branding:
|
||||
edit_window.edit(nav.children[index], false, "branding")
|
||||
break
|
||||
case FileAction.Select:
|
||||
select_node(index)
|
||||
edit_window.edit(node, false, "branding")
|
||||
break
|
||||
case FileAction.Menu:
|
||||
file_menu.open(nav.children[index], orig.target, orig)
|
||||
file_menu.open(node, orig.target, orig)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -114,45 +116,17 @@ const delete_selected = async () => {
|
||||
try {
|
||||
loading_start()
|
||||
|
||||
// Save all promises with deletion requests in an array
|
||||
let promises: Promise<GenericResponse>[] = []
|
||||
nav.children.forEach(child => {
|
||||
if (!child.fm_selected) {
|
||||
return
|
||||
}
|
||||
promises.push(fs_trash(child.path))
|
||||
})
|
||||
|
||||
// Wait for all the promises to finish
|
||||
await Promise.all(promises)
|
||||
await Promise.all([...nav.selection.values()].map(node => fs_trash(node.path)))
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
alert("Delete failed: " + err.message + " ("+err.value+")")
|
||||
} finally {
|
||||
viewing_mode()
|
||||
nav.set_selecting(false)
|
||||
loading_finish()
|
||||
await nav.reload()
|
||||
}
|
||||
}
|
||||
|
||||
// Mode switches
|
||||
|
||||
const selecting_mode = () => {
|
||||
mode = "selecting"
|
||||
}
|
||||
const viewing_mode = () => {
|
||||
// Remove any items which we were moving
|
||||
moving_items = []
|
||||
|
||||
// Unmark all the selected files and return to viewing mode
|
||||
nav.children.forEach((child, i) => {
|
||||
if (child.fm_selected) {
|
||||
nav.children[i].fm_selected = false
|
||||
}
|
||||
})
|
||||
mode = "viewing"
|
||||
}
|
||||
|
||||
const toggle_view = () => {
|
||||
if (directory_view === "list") {
|
||||
directory_view = "gallery"
|
||||
@@ -169,14 +143,7 @@ const toggle_large_icons = () => {
|
||||
localStorage.setItem("large_icons", JSON.stringify(large_icons))
|
||||
}
|
||||
|
||||
// Moving functions
|
||||
|
||||
let moving_items: FSNode[] = []
|
||||
|
||||
// We need to detect if shift is pressed so we can select multiple items
|
||||
let shift_pressed = false
|
||||
let last_selected_node = -1
|
||||
const keypress = (e: KeyboardEvent) => {
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
(document.activeElement as any).type !== undefined &&
|
||||
(document.activeElement as any).type === "text"
|
||||
@@ -184,102 +151,32 @@ const keypress = (e: KeyboardEvent) => {
|
||||
return // Prevent shortcuts from interfering with input fields
|
||||
}
|
||||
|
||||
if (e.key === "Shift") {
|
||||
shift_pressed = e.type === "keydown"
|
||||
} else if (e.type === "keydown" && e.key === "a" && e.ctrlKey) {
|
||||
// CTRL + A selects all files
|
||||
selecting_mode()
|
||||
for (let i = 0; i < nav.children.length; i++) {
|
||||
nav.children[i].fm_selected = true
|
||||
if (e.key === "a" && e.ctrlKey) {
|
||||
// CTRL + A selects all files which are visible
|
||||
for (const child of nav.children) {
|
||||
if (show_hidden || !child.is_hidden()) {
|
||||
nav.selection.set(child.path, child)
|
||||
}
|
||||
}
|
||||
nav.set_selecting(true)
|
||||
e.preventDefault()
|
||||
} else if (e.type === "keydown" && e.key === "Escape" && (mode !== "viewing" || creating_dir)) {
|
||||
} else if (e.key === "Escape" && (nav.selecting || creating_dir)) {
|
||||
// When escape is pressed we return to viewing mode
|
||||
viewing_mode()
|
||||
nav.set_selecting(false)
|
||||
creating_dir = false
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
const select_node = (index: number) => {
|
||||
mode = "selecting"
|
||||
if (shift_pressed) {
|
||||
// If shift is pressed we do a range select. We select all files between
|
||||
// the last selected file and the file that is being selected now
|
||||
let id_low = Math.min(last_selected_node, index)
|
||||
let id_high = Math.max(last_selected_node, index)
|
||||
|
||||
for (let i = id_low; i <= id_high; i++) {
|
||||
if (i != last_selected_node) {
|
||||
nav.children[i].fm_selected = !nav.children[i].fm_selected
|
||||
}
|
||||
}
|
||||
} else {
|
||||
nav.children[index].fm_selected = !nav.children[index].fm_selected
|
||||
}
|
||||
|
||||
last_selected_node = index
|
||||
}
|
||||
|
||||
// When the directory is reloaded we want to keep our selection, so this
|
||||
// function watches the children array for changes and updates the selection
|
||||
// when it changes
|
||||
// Runs every time the navigator changes
|
||||
$effect.pre(() => {
|
||||
if (!$nav.selecting) {
|
||||
moving = false
|
||||
}
|
||||
creating_dir = false
|
||||
|
||||
// Highlight the files which were previously selected
|
||||
for (let i = 0; i < $nav.children.length; i++) {
|
||||
for (let j = 0; j < moving_items.length; j++) {
|
||||
if (moving_items[j].path === $nav.children[i].path) {
|
||||
$nav.children[i].fm_selected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let moving_files = $state(0)
|
||||
let moving_directories = $state(0)
|
||||
const move_start = () => {
|
||||
moving_files = 0
|
||||
moving_directories = 0
|
||||
moving_items = nav.children.reduce((acc, child) => {
|
||||
if (child.fm_selected) {
|
||||
if (child.type === "file") {
|
||||
moving_files++
|
||||
} else if (child.type === "dir") {
|
||||
moving_directories++
|
||||
}
|
||||
acc.push(child)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
mode = "moving"
|
||||
}
|
||||
|
||||
const move_here = async () => {
|
||||
const target_dir = nav.base.path + "/"
|
||||
try {
|
||||
loading_start()
|
||||
|
||||
let promises = []
|
||||
moving_items.forEach(item => {
|
||||
console.log("moving", item.path, "to", target_dir + item.name)
|
||||
promises.push(fs_rename(item.path, target_dir + item.name))
|
||||
})
|
||||
|
||||
// Wait for all the promises to finish
|
||||
await Promise.all(promises)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
alert("Move failed: " + err.message + " ("+err.value+")")
|
||||
} finally {
|
||||
viewing_mode()
|
||||
nav.reload()
|
||||
loading_finish()
|
||||
}
|
||||
}
|
||||
|
||||
// The toolbar is shown in the bar at the top of the page, under the breadcrumbs.
|
||||
// Other pages don't have it
|
||||
onMount(() => {
|
||||
@@ -289,27 +186,30 @@ onMount(() => {
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={keypress}
|
||||
onkeyup={keypress}
|
||||
onkeydown={keydown}
|
||||
onscroll={() => scroll_positions.set(nav.base.path, window.scrollY)}
|
||||
/>
|
||||
|
||||
{#snippet nav_buttons()}
|
||||
<button onclick={navigate_back} use:drag_click title="Back">
|
||||
<i class="icon">arrow_back</i>
|
||||
</button>
|
||||
<button onclick={() => nav.navigate_up()} use:drag_click disabled={$nav.path.length <= 1} title="Up">
|
||||
<i class="icon">north</i>
|
||||
</button>
|
||||
<button onclick={() => nav.reload()} use:drag_click title="Refresh directory listing">
|
||||
<i class="icon">refresh</i>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet toolbar()}
|
||||
{#if mode === "viewing"}
|
||||
{#if !$nav.selecting}
|
||||
<div class="toolbar">
|
||||
<div class="toolbar_left">
|
||||
<button onclick={() => {search_bar.open()}} title="Press / to search">
|
||||
<i class="icon">search</i>
|
||||
</button>
|
||||
<button onclick={navigate_back} title="Back">
|
||||
<i class="icon">arrow_back</i>
|
||||
</button>
|
||||
<button onclick={() => nav.navigate_up()} disabled={$nav.path.length <= 1} title="Up">
|
||||
<i class="icon">north</i>
|
||||
</button>
|
||||
<button onclick={() => nav.reload()} title="Refresh directory listing">
|
||||
<i class="icon">refresh</i>
|
||||
</button>
|
||||
{@render nav_buttons()}
|
||||
{#if $nav.children.some(c => c.name === ".Trash" && c.type === "dir")}
|
||||
<button
|
||||
onclick={() => nav.navigate($nav.children.find(c => c.name === ".Trash").path, true)}
|
||||
@@ -352,32 +252,40 @@ onMount(() => {
|
||||
|
||||
<Button click={() => {creating_dir = !creating_dir}} highlight={creating_dir} icon="create_new_folder" title="Make folder"/>
|
||||
|
||||
<button onclick={selecting_mode} title="Select and delete files">
|
||||
<button onclick={() => nav.set_selecting(true)} title="Select and delete files">
|
||||
<i class="icon">select_all</i>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if mode === "selecting"}
|
||||
{:else}
|
||||
<div class="toolbar toolbar_edit">
|
||||
<Button click={viewing_mode} icon="close"/>
|
||||
<div class="toolbar_spacer">Selecting files</div>
|
||||
<Button click={move_start} icon="drive_file_move" label="Move"/>
|
||||
<button onclick={delete_selected} class="button_red">
|
||||
<i class="icon">delete</i>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{:else if mode === "moving"}
|
||||
<div class="toolbar toolbar_edit">
|
||||
<Button click={viewing_mode} icon="close"/>
|
||||
<Button click={() => nav.navigate_up()} disabled={$nav.path.length <= 1} icon="north"/>
|
||||
<div class="toolbar_spacer">
|
||||
Moving {moving_files} files and {moving_directories} directories
|
||||
<div>
|
||||
<Button click={() => nav.set_selecting(false)} icon="close"/>
|
||||
{@render nav_buttons()}
|
||||
</div>
|
||||
<div class="toolbar_spacer">
|
||||
{moving ? "Moving" : "Selected"} {$nav.selection.size} items
|
||||
</div>
|
||||
<div>
|
||||
{#if moving}
|
||||
<Button click={() => {creating_dir = !creating_dir}} highlight={creating_dir} icon="create_new_folder" title="Make folder"/>
|
||||
<Button
|
||||
click={() => nav.move_selection(nav.base.path)}
|
||||
disabled={!$nav.can_move($nav.base.path)}
|
||||
highlight
|
||||
icon="done"
|
||||
label="Move here"
|
||||
/>
|
||||
{:else}
|
||||
<Button click={() => {moving = true}} icon="drive_file_move" label="Move"/>
|
||||
<button onclick={delete_selected} class="button_red">
|
||||
<i class="icon">delete</i>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<Button click={() => {creating_dir = !creating_dir}} highlight={creating_dir} icon="create_new_folder" title="Make folder"/>
|
||||
<Button click={move_here} highlight icon="done" label="Move here"/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -388,6 +296,7 @@ onMount(() => {
|
||||
|
||||
<div
|
||||
class="container"
|
||||
use:move_target={{nav, node: () => nav.base}}
|
||||
use:drop_target={{
|
||||
upload: (files) => upload_widget.upload_files(files),
|
||||
shadow: "var(--accent_color) 0 0 10px 2px inset",
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
export type FileEvent = {
|
||||
index: number,
|
||||
action: FileAction,
|
||||
original: MouseEvent,
|
||||
}
|
||||
export enum FileAction {
|
||||
Click,
|
||||
Context,
|
||||
Edit,
|
||||
Share,
|
||||
Branding,
|
||||
Select,
|
||||
Download,
|
||||
Menu,
|
||||
Drag,
|
||||
}
|
||||
export type FileActionHandler = (
|
||||
action: FileAction,
|
||||
|
||||
@@ -63,6 +63,9 @@ const delete_node = async () => {
|
||||
<Button click={() => {dialog.close(); bookmark_add(node)}} icon="bookmark_add" label="Add bookmark"/>
|
||||
{/if}
|
||||
{#if $nav.permissions.write}
|
||||
{#if $nav.children.includes(node)}
|
||||
<Button click={() => {dialog.close(); nav.select(node)}} icon="select_all" label="Select"/>
|
||||
{/if}
|
||||
<Button click={() => {dialog.close(); delete_node()}} icon="delete" label="Delete"/>
|
||||
{#if edit_window !== null}
|
||||
<Button click={() => {dialog.close(); edit_window.edit(node, false, "file")}} icon="edit" label="Edit"/>
|
||||
|
||||
@@ -24,16 +24,10 @@ let {
|
||||
|
||||
export const open = (path: string) => {
|
||||
modal.show()
|
||||
nav.set_selecting(false)
|
||||
nav.navigate(path, false)
|
||||
}
|
||||
|
||||
let selected_files = $derived($nav.children.reduce((acc, file) => {
|
||||
if (file.fm_selected) {
|
||||
acc++
|
||||
}
|
||||
return acc
|
||||
}, 0))
|
||||
|
||||
// Navigation functions
|
||||
|
||||
const file_event: FileActionHandler = (action: FileAction, index: number, orig: Event) => {
|
||||
@@ -43,20 +37,16 @@ const file_event: FileActionHandler = (action: FileAction, index: number, orig:
|
||||
if (nav.children[index].type === "dir") {
|
||||
nav.navigate(nav.children[index].path, true)
|
||||
} else {
|
||||
select_node(index)
|
||||
select_node(index, orig)
|
||||
}
|
||||
break
|
||||
case FileAction.Context:
|
||||
// If this is a touch event we will select the item
|
||||
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
|
||||
orig.preventDefault()
|
||||
select_node(index)
|
||||
select_node(index, orig)
|
||||
}
|
||||
break
|
||||
case FileAction.Select:
|
||||
orig.preventDefault()
|
||||
nav.children[index].fm_selected = !nav.children[index].fm_selected
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,50 +62,16 @@ const toggle_view = () => {
|
||||
localStorage.setItem("directory_view", directory_view)
|
||||
}
|
||||
|
||||
// We need to detect if shift is pressed so we can select multiple items
|
||||
let shift_pressed = false
|
||||
let last_selected_node = -1
|
||||
const detect_shift = (e: KeyboardEvent) => {
|
||||
if (e.key === "Shift") {
|
||||
shift_pressed = e.type === "keydown"
|
||||
const select_node = (index: number, e: Event) => {
|
||||
if (!select_multiple) {
|
||||
nav.selection.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const select_node = (index: number) => {
|
||||
if (select_multiple && shift_pressed) {
|
||||
// If shift is pressed we do a range select. We select all files between
|
||||
// the last selected file and the file that is being selected now
|
||||
let id_low = Math.min(last_selected_node, index)
|
||||
let id_high = Math.max(last_selected_node, index)
|
||||
|
||||
for (let i = id_low; i <= id_high; i++) {
|
||||
if (i != last_selected_node) {
|
||||
nav.children[i].fm_selected = !nav.children[i].fm_selected
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Deselect all other entries first
|
||||
if (!select_multiple) {
|
||||
for (let i = 0; i < nav.children.length; i++) {
|
||||
nav.children[i].fm_selected = false
|
||||
}
|
||||
}
|
||||
nav.children[index].fm_selected = !nav.children[index].fm_selected
|
||||
}
|
||||
|
||||
last_selected_node = index
|
||||
nav.select(nav.children[index], select_multiple && (e as MouseEvent).shiftKey, show_hidden)
|
||||
}
|
||||
|
||||
let done = () => {
|
||||
let selected_files: FSNode[] = []
|
||||
for (let i = 0; i < nav.children.length; i++) {
|
||||
if (nav.children[i].fm_selected) {
|
||||
selected_files.push(nav.children[i])
|
||||
}
|
||||
}
|
||||
|
||||
if (selected_files.length > 0) {
|
||||
callback(selected_files)
|
||||
if (nav.selection.size > 0) {
|
||||
callback([...nav.selection.values()])
|
||||
}
|
||||
modal.hide()
|
||||
}
|
||||
@@ -131,8 +87,6 @@ onMount(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={detect_shift} onkeyup={detect_shift} />
|
||||
|
||||
<Modal bind:this={modal} width="900px">
|
||||
{#snippet header()}
|
||||
<div class="header" >
|
||||
@@ -147,7 +101,7 @@ onMount(() => {
|
||||
</button>
|
||||
|
||||
<div class="title">
|
||||
Selected {selected_files} files
|
||||
Selected {$nav.selection.size} files
|
||||
</div>
|
||||
|
||||
<button onclick={() => {show_hidden = !show_hidden}} title="Toggle hidden files">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { fs_node_icon, fs_node_type, fs_encode_path } from "lib/FilesystemAPI.svelte";
|
||||
import type { FSNavigator } from "filesystem/FSNavigator";
|
||||
import { move_target, type FSNavigator } from "filesystem/FSNavigator";
|
||||
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
||||
|
||||
let {
|
||||
@@ -22,7 +22,9 @@ let {
|
||||
href={"/d"+fs_encode_path(child.path)}
|
||||
onclick={e => file_event(FileAction.Click, index, e)}
|
||||
oncontextmenu={e => file_event(FileAction.Context, index, e)}
|
||||
class:selected={child.fm_selected}
|
||||
ondragstart={e => file_event(FileAction.Drag, index, e)}
|
||||
use:move_target={{nav, node: () => child}}
|
||||
class:selected={$nav.selection.has(child.path)}
|
||||
class:hidden={child.name.startsWith(".") && !show_hidden}
|
||||
class:large_icons
|
||||
title={child.name}
|
||||
@@ -33,6 +35,7 @@ let {
|
||||
src={fs_node_icon(child, 256, 256)}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="node_name">
|
||||
{child.name}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { formatDataVolume, formatDate, formatThousands } from "util/Formatting";
|
||||
import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte"
|
||||
import type { FSNavigator } from "filesystem/FSNavigator";
|
||||
import { move_target, type FSNavigator } from "filesystem/FSNavigator";
|
||||
import SortButton from "layout/SortButton.svelte";
|
||||
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
||||
import Dialog from "layout/Dialog.svelte";
|
||||
@@ -15,7 +15,8 @@ let {
|
||||
show_hidden = false,
|
||||
large_icons = false,
|
||||
hide_edit = false,
|
||||
hide_branding = false
|
||||
hide_branding = false,
|
||||
trash = false
|
||||
}: {
|
||||
nav: FSNavigator
|
||||
file_event: FileActionHandler
|
||||
@@ -23,6 +24,7 @@ let {
|
||||
large_icons?: boolean
|
||||
hide_edit?: boolean
|
||||
hide_branding?: boolean
|
||||
trash?: boolean
|
||||
} = $props();
|
||||
|
||||
let dialog: Dialog = $state()
|
||||
@@ -78,6 +80,18 @@ onMount(load_settings)
|
||||
Name
|
||||
</SortButton>
|
||||
</td>
|
||||
{#if trash}
|
||||
<td class="hide_small">
|
||||
<SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="original_path">
|
||||
Original location
|
||||
</SortButton>
|
||||
</td>
|
||||
<td class="hide_small">
|
||||
<SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="deletion_date">
|
||||
Deleted on
|
||||
</SortButton>
|
||||
</td>
|
||||
{/if}
|
||||
{#each columns as col}
|
||||
<td class="hide_small" class:hidden={!config[col.field]}>
|
||||
<SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field={col.field}>
|
||||
@@ -95,18 +109,27 @@ onMount(load_settings)
|
||||
<tr
|
||||
onclick={e => file_event(FileAction.Click, index, e)}
|
||||
oncontextmenu={e => file_event(FileAction.Context, index, e)}
|
||||
ondragstart={e => file_event(FileAction.Drag, index, e)}
|
||||
use:move_target={{nav, node: () => child}}
|
||||
draggable="true"
|
||||
class="node"
|
||||
class:node_selected={child.fm_selected}
|
||||
class:node_selected={$nav.selection.has(child.path)}
|
||||
class:hidden={child.name.startsWith(".") && !show_hidden}
|
||||
>
|
||||
<td>
|
||||
<img src={fs_node_icon(child, 64, 64)} class="node_icon" class:large_icons loading="lazy" alt="icon"/>
|
||||
<img src={fs_node_icon(child, 64, 64)} class="node_icon" class:large_icons loading="lazy" alt="icon" draggable="false"/>
|
||||
</td>
|
||||
<td class="node_name">
|
||||
<a class="title_link" href={"/d"+fs_encode_path(child.path)}>
|
||||
<td class="node_name" class:half={trash}>
|
||||
<a class="title_link" href={"/d"+fs_encode_path(child.path)} draggable="false">
|
||||
{child.name}
|
||||
</a>
|
||||
</td>
|
||||
{#if trash}
|
||||
<td class="node_name half hide_small">{child.original_path}</td>
|
||||
<td class="node_size hide_small">
|
||||
{formatDate(child.deletion_date, true, true, false)}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="node_size hide_small" class:hidden={!config.modified}>
|
||||
{formatDate(child.modified, true, true, false)}
|
||||
</td>
|
||||
@@ -213,8 +236,8 @@ td {
|
||||
text-decoration: none;
|
||||
}
|
||||
.node.node_selected {
|
||||
background-color: var(--accent_color) !important;
|
||||
color: var(--accent_text_color);
|
||||
background-color: var(--accent_shade) !important;
|
||||
color: var(--link_color);
|
||||
}
|
||||
td {
|
||||
vertical-align: middle;
|
||||
@@ -231,9 +254,13 @@ td {
|
||||
line-height: 1.2em;
|
||||
word-break: break-all;
|
||||
}
|
||||
/* In the trash the name shares its width with the original location */
|
||||
.half {
|
||||
width: 50%;
|
||||
}
|
||||
.title_link {
|
||||
text-decoration: none;
|
||||
color: var(--body_text_color);
|
||||
color: unset;
|
||||
}
|
||||
.node_size {
|
||||
min-width: 5em;
|
||||
@@ -265,5 +292,8 @@ td {
|
||||
.hide_small {
|
||||
display: none;
|
||||
}
|
||||
.half {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { formatDataVolume, formatDate } from "util/Formatting";
|
||||
import { fs_check_response, fs_encode_path, fs_get_trash, fs_node_icon, fs_path_url, type TrashEntry } from "lib/FilesystemAPI.svelte";
|
||||
import { loading_finish, loading_run, loading_start, loading_store } from "lib/Loading";
|
||||
import { formatDataVolume } from "util/Formatting";
|
||||
import { fs_check_response, fs_get_trash, fs_path_url, fs_rename, fs_trash, type FSNode } from "lib/FilesystemAPI.svelte";
|
||||
import { loading_run } from "lib/Loading";
|
||||
import { onMount } from "svelte";
|
||||
import { breadcrumbs_store } from "wrap/HeaderStore";
|
||||
import { toolbar_store } from "wrap/HeaderStore";
|
||||
import { FSNavigator } from "filesystem/FSNavigator";
|
||||
import Button from "layout/Button.svelte";
|
||||
import ListView from "./ListView.svelte";
|
||||
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
||||
|
||||
let {
|
||||
nav,
|
||||
@@ -14,214 +17,157 @@ let {
|
||||
root_path?: string
|
||||
} = $props();
|
||||
|
||||
let entries: TrashEntry[] = $state([]);
|
||||
let error: string = $state("");
|
||||
// The trash entries are not the children of the .Trash directory, so they get a
|
||||
// navigator of their own for the ListView to list and sort
|
||||
const trash = new FSNavigator(false)
|
||||
|
||||
const load = async () => {
|
||||
loading_run(async () => {
|
||||
try {
|
||||
entries = await fs_get_trash(root_path)
|
||||
error = "";
|
||||
} catch (err: any) {
|
||||
error = err?.message ?? JSON.stringify(err);
|
||||
let size = $derived($trash.children.reduce(
|
||||
(acc, node) => acc + (node.type === "dir" ? node.recursive_size : node.file_size), 0
|
||||
))
|
||||
|
||||
const load = () => loading_run(async () => {
|
||||
try {
|
||||
trash.children = await fs_get_trash(root_path)
|
||||
} catch (err: any) {
|
||||
alert("Failed to load the trash bin: " + (err.message ?? err))
|
||||
}
|
||||
trash.selecting = false
|
||||
trash.selection.clear()
|
||||
trash.sort_children("")
|
||||
})
|
||||
|
||||
const file_event: FileActionHandler = (action, index, e) => {
|
||||
if (action === FileAction.Drag) {
|
||||
return
|
||||
} else if (action === FileAction.Context) {
|
||||
// On a touch screen a long press starts selecting, like in the file
|
||||
// manager. A right click is left to the browser's own menu
|
||||
if (!navigator.maxTouchPoints) {
|
||||
return
|
||||
}
|
||||
})
|
||||
};
|
||||
trash.selecting = true
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const delete_entry = async (entry: TrashEntry) => {
|
||||
if (!confirm(`Permanently delete "${entry.node.name}"?`)) return;
|
||||
try {
|
||||
loading_start();
|
||||
await fs_check_response(
|
||||
await fetch(fs_path_url(entry.node.path) + "?trash", { method: "DELETE" })
|
||||
);
|
||||
entries = entries.filter(e => e.node.id !== entry.node.id);
|
||||
} catch (err: any) {
|
||||
alert(err?.message ?? JSON.stringify(err));
|
||||
} finally {
|
||||
loading_finish();
|
||||
if (trash.selecting) {
|
||||
trash.select(trash.children[index], (e as MouseEvent).shiftKey)
|
||||
} else {
|
||||
nav.navigate(trash.children[index].path, true)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const restore_entry = async (entry: TrashEntry) => {
|
||||
if (!entry.original_path) {
|
||||
alert("Original path is not known, cannot restore this file.");
|
||||
return;
|
||||
const selected = () => [...trash.selection.values()]
|
||||
|
||||
// Runs an action on all the selected entries and reloads the listing
|
||||
const run_selected = async (action: (node: FSNode) => Promise<any>) => {
|
||||
const results = await loading_run(() => Promise.allSettled(selected().map(action)))
|
||||
const failed = results.filter(r => r.status === "rejected") as PromiseRejectedResult[]
|
||||
if (failed.length !== 0) {
|
||||
alert(
|
||||
failed.length + " of " + results.length + " failed: " +
|
||||
(failed[0].reason.message ?? failed[0].reason)
|
||||
)
|
||||
}
|
||||
try {
|
||||
loading_start();
|
||||
const form = new FormData();
|
||||
form.append("action", "rename");
|
||||
form.append("target", "/me" + entry.original_path);
|
||||
await fs_check_response(
|
||||
await fetch(fs_path_url(entry.node.path), { method: "POST", body: form })
|
||||
);
|
||||
entries = entries.filter(e => e.node.id !== entry.node.id);
|
||||
} catch (err: any) {
|
||||
alert(err?.message ?? JSON.stringify(err));
|
||||
} finally {
|
||||
loading_finish();
|
||||
await load()
|
||||
}
|
||||
|
||||
const restore = () => run_selected(async node => {
|
||||
if (!node.original_path) {
|
||||
throw new Error("the original location of " + node.name + " is not known")
|
||||
}
|
||||
};
|
||||
return fs_rename(node.path, root_path + node.original_path)
|
||||
})
|
||||
|
||||
const delete_selected = () => {
|
||||
if (confirm(`Permanently delete ${selected().length} of the items in the trash?`)) {
|
||||
run_selected(node => fs_trash(node.path))
|
||||
}
|
||||
}
|
||||
|
||||
const empty_trash = async () => {
|
||||
if (!confirm("Permanently delete all items in the trash?")) return;
|
||||
try {
|
||||
loading_start();
|
||||
await fs_check_response(
|
||||
await fetch(fs_path_url(root_path) + "?empty_trash", { method: "DELETE" })
|
||||
);
|
||||
entries = [];
|
||||
} catch (err: any) {
|
||||
alert(err?.message ?? JSON.stringify(err));
|
||||
} finally {
|
||||
loading_finish();
|
||||
if (!confirm("Permanently delete all items in the trash?")) {
|
||||
return
|
||||
}
|
||||
};
|
||||
try {
|
||||
await loading_run(async () => fs_check_response(
|
||||
await fetch(fs_path_url(root_path) + "?empty_trash", { method: "DELETE" })
|
||||
))
|
||||
} catch (err: any) {
|
||||
alert(err.message ?? err)
|
||||
}
|
||||
await load()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
breadcrumbs_store.set(breadcrumbs)
|
||||
|
||||
toolbar_store.set(toolbar)
|
||||
load()
|
||||
});
|
||||
return () => toolbar_store.set(null)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
{#snippet breadcrumbs()}
|
||||
<i class="icon">delete</i>
|
||||
<div>
|
||||
Trash bin
|
||||
{#if $loading_store === 0 && entries.length === 0}
|
||||
(empty)
|
||||
{:else if entries.length !== 0}
|
||||
({formatDataVolume(entries.reduce(
|
||||
(acc, entry) => acc + entry.node.file_size, 0
|
||||
), 3)})
|
||||
{/if}
|
||||
</div>
|
||||
<div class="separator"></div>
|
||||
<button onclick={() => window.history.back()} title="Back">
|
||||
<i class="icon">arrow_back</i>
|
||||
</button>
|
||||
<button onclick={() => nav.navigate_up()} disabled={$nav.path.length <= 1} title="Up">
|
||||
<i class="icon">north</i>
|
||||
</button>
|
||||
<button onclick={() => nav.reload()} title="Refresh directory listing">
|
||||
<i class="icon">refresh</i>
|
||||
</button>
|
||||
{#if entries.length !== 0}
|
||||
<button class="button button_red" onclick={empty_trash}>
|
||||
{#snippet toolbar()}
|
||||
{#if $trash.selecting}
|
||||
<div class="toolbar toolbar_edit">
|
||||
<Button icon="close" click={() => trash.set_selecting(false)}/>
|
||||
<div class="toolbar_spacer">Selecting files</div>
|
||||
<Button click={restore} icon="restore_from_trash" label="Restore"/>
|
||||
<button onclick={delete_selected} class="button_red">
|
||||
<i class="icon">delete_forever</i>
|
||||
<span>Delete all</span>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<button onclick={() => history.back()} title="Back">
|
||||
<i class="icon">arrow_back</i>
|
||||
</button>
|
||||
<button onclick={() => nav.navigate_up()} title="Up">
|
||||
<i class="icon">north</i>
|
||||
</button>
|
||||
<button onclick={load} title="Refresh trash bin">
|
||||
<i class="icon">refresh</i>
|
||||
</button>
|
||||
</div>
|
||||
<div>Trash bin ({formatDataVolume(size, 3)})</div>
|
||||
<div>
|
||||
{#if $trash.children.length !== 0}
|
||||
<button onclick={() => trash.set_selecting(true)} title="Select files to restore or delete">
|
||||
<i class="icon">select_all</i>
|
||||
</button>
|
||||
<button onclick={empty_trash} class="button_red">
|
||||
<i class="icon">delete_forever</i>
|
||||
Delete all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if entries.length > 0}
|
||||
<table class="trash_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>Name</td>
|
||||
<td class="hide_small">Original location</td>
|
||||
<td class="hide_small">Deleted at</td>
|
||||
<td class="hide_small">Size</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entries as entry (entry.node.id)}
|
||||
<tr class="entry">
|
||||
<td class="icon_cell">
|
||||
<img
|
||||
src={fs_node_icon(entry.node, 32, 32)}
|
||||
class="node_icon"
|
||||
alt="icon"
|
||||
/>
|
||||
</td>
|
||||
<td class="name_cell">
|
||||
<a class="entry_link" href={"/d" + fs_encode_path(entry.node.path)}>
|
||||
{entry.node.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="hide_small">
|
||||
{entry.original_path ? entry.original_path.slice(0, -entry.node.name.length) : "—"}
|
||||
</td>
|
||||
<td class="hide_small">
|
||||
{entry.deletion_date ? formatDate(entry.deletion_date) : "—"}
|
||||
</td>
|
||||
<td class="hide_small">
|
||||
{#if entry.node.type === "file"}
|
||||
{formatDataVolume(entry.node.file_size, 3)}
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="row_actions">
|
||||
<button title="Restore to original location" onclick={() => restore_entry(entry)}>
|
||||
<i class="icon">restore_from_trash</i>
|
||||
</button>
|
||||
<button class="button button_red" title="Delete permanently" onclick={() => delete_entry(entry)}>
|
||||
<i class="icon">delete_forever</i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
<ListView nav={trash} file_event={file_event} show_hidden hide_edit hide_branding trash/>
|
||||
|
||||
<style>
|
||||
.separator {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.error {
|
||||
color: var(--red_color);
|
||||
}
|
||||
.trash_table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--body_background);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.trash_table td {
|
||||
padding: 0;
|
||||
}
|
||||
.entry {
|
||||
cursor: default;
|
||||
/* Same as the toolbar of the file manager */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--separator);
|
||||
}
|
||||
.entry:hover {
|
||||
background: var(--input_hover_background);
|
||||
}
|
||||
.icon_cell {
|
||||
width: 2.5em;
|
||||
}
|
||||
.node_icon {
|
||||
display: block;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
object-fit: contain;
|
||||
}
|
||||
.name_cell {
|
||||
word-break: break-all;
|
||||
}
|
||||
.entry_link {
|
||||
color: var(--body_text_color);
|
||||
text-decoration: none;
|
||||
}
|
||||
.entry_link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.row_actions {
|
||||
.toolbar > * {
|
||||
flex: 0 0 content;
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
flex-direction: row;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.hide_small {
|
||||
display: none;
|
||||
}
|
||||
.toolbar_spacer {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.toolbar_edit {
|
||||
background-color: var(--green_tint);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,13 +12,16 @@ export type Bookmark = {
|
||||
label: string,
|
||||
}
|
||||
|
||||
export let bookmarks_failed = writable(false)
|
||||
|
||||
export let bookmarks_store = writable<Bookmark[]>(
|
||||
[],
|
||||
(set: (value: Bookmark[]) => void) => {
|
||||
bookmarks_get().then((bm: Bookmark[]) => {
|
||||
set(bm)
|
||||
}).catch((err: any) => {
|
||||
alert("Could not fetch bookmarks:\n" + JSON.stringify(err))
|
||||
console.error("Could not fetch bookmarks", err)
|
||||
bookmarks_failed.set(true)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -52,10 +52,9 @@ export class FSNode {
|
||||
user_permissions?: { [index: string]: FSPermissions }
|
||||
password_permissions?: { [index: string]: FSPermissions }
|
||||
|
||||
// Added by us
|
||||
|
||||
// Indicates whether the file is selected in the file manager
|
||||
fm_selected?: boolean = $state(false)
|
||||
// Only set on the nodes returned by fs_get_trash
|
||||
original_path?: string
|
||||
deletion_date?: string
|
||||
|
||||
is_shared = (): boolean => {
|
||||
return (this.link_permissions !== undefined && this.link_permissions.read === true) ||
|
||||
@@ -251,22 +250,15 @@ export const fs_trash = async (path: string) => {
|
||||
) as GenericResponse
|
||||
}
|
||||
|
||||
export type TrashEntry = {
|
||||
node: FSNode;
|
||||
original_path: string;
|
||||
deletion_date: string;
|
||||
};
|
||||
|
||||
export const fs_get_trash = async (path: string) => {
|
||||
let resp = await fs_check_response(
|
||||
const resp = await fs_check_response(
|
||||
await fetch(fs_path_url(path) + "?trash")
|
||||
) as TrashEntry[]
|
||||
resp.forEach((entry, index) => {
|
||||
resp[index].node = Object.assign(new FSNode(), entry.node)
|
||||
})
|
||||
) as { node: FSNode, original_path: string, deletion_date: string }[]
|
||||
|
||||
return resp
|
||||
};
|
||||
// The trash metadata is moved into the node, so the trash can be listed
|
||||
// like the children of a directory
|
||||
return resp.map(({ node, ...info }) => Object.assign(new FSNode(), node, info))
|
||||
}
|
||||
|
||||
export const fs_search = async (path: string, term: string, limit = 10) => {
|
||||
return await fs_check_response(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { bookmarks_save, bookmarks_store } from "lib/Bookmarks";
|
||||
import { bookmarks_failed, bookmarks_save, bookmarks_store } from "lib/Bookmarks";
|
||||
import { fs_encode_path, fs_thumbnail_url } from "lib/FilesystemAPI.svelte";
|
||||
import { global_navigator, move_target } from "filesystem/FSNavigator";
|
||||
import { highlight_current_page } from "lib/HighlightCurrentPage";
|
||||
import MenuEntry from "./MenuEntry.svelte";
|
||||
import { flip } from "svelte/animate";
|
||||
@@ -94,7 +95,8 @@ const drop = (e: DragEvent, drop_idx: number) => {
|
||||
ondragstart={e => drag(e, index)}
|
||||
ondrop={e => drop(e, index)}
|
||||
ondragover={e => {e.preventDefault(); e.stopPropagation()}}
|
||||
ondragenter={() => hovering = index}
|
||||
ondragenter={() => {if (editing) hovering = index}}
|
||||
use:move_target={{nav: global_navigator, node: () => bookmark}}
|
||||
ondragend={() => {hovering = -1}}
|
||||
class:highlight={hovering === index}
|
||||
animate:flip={{duration: 300}}
|
||||
@@ -122,6 +124,8 @@ const drop = (e: DragEvent, drop_idx: number) => {
|
||||
{/each}
|
||||
{/snippet}
|
||||
</MenuEntry>
|
||||
{:else if $bookmarks_failed}
|
||||
<div>Could not load bookmarks</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { global_navigator } from "filesystem/FSNavigator";
|
||||
import { global_navigator, move_target } from "filesystem/FSNavigator";
|
||||
import { fs_encode_path, fs_node_icon, FSNode } from "lib/FilesystemAPI.svelte";
|
||||
import { highlight_current_page } from "lib/HighlightCurrentPage";
|
||||
import { onMount } from "svelte";
|
||||
@@ -39,7 +39,7 @@ onMount(() => {
|
||||
{#snippet body()}
|
||||
{#each $global_navigator.path.slice(0, $global_navigator.path.length-1) as node (node.id)}
|
||||
{#if node.type === "dir"}
|
||||
<div class="row">
|
||||
<div class="row" use:move_target={{nav: global_navigator, node: () => node}}>
|
||||
<a class="button" href="/d{fs_encode_path(node.path)}" title="{node.name}">
|
||||
<img class="thumbnail" src="{fs_node_icon(node, 32, 32)}" alt="{node.name}"/>
|
||||
<span>{node.name}</span>
|
||||
@@ -64,7 +64,7 @@ onMount(() => {
|
||||
{#snippet body()}
|
||||
{#each siblings as node (node.id)}
|
||||
{#if !node.is_hidden()}
|
||||
<div class="row">
|
||||
<div class="row" use:move_target={{nav: global_navigator, node: () => node}}>
|
||||
<a class="button" href="/d{fs_encode_path(node.path)}" title="{node.name}" use:highlight_current_page>
|
||||
<img class="thumbnail" src="{fs_node_icon(node, 32, 32)}" alt="{node.name}"/>
|
||||
<span>{node.name}</span>
|
||||
|
||||
Reference in New Issue
Block a user