Add drag and drop to file manager
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fs_encode_path } from "lib/FilesystemAPI.svelte";
|
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 FileMenu from "./filemanager/FileMenu.svelte";
|
||||||
import EditWindow from "./edit_window/EditWindow.svelte";
|
import EditWindow from "./edit_window/EditWindow.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
@@ -29,6 +29,7 @@ onMount(() => {
|
|||||||
href={"/d"+fs_encode_path(node.path)}
|
href={"/d"+fs_encode_path(node.path)}
|
||||||
class="breadcrumb button flat"
|
class="breadcrumb button flat"
|
||||||
use:path_link={{nav: nav, node: node}}
|
use:path_link={{nav: nav, node: node}}
|
||||||
|
use:move_target={{nav, node: () => node}}
|
||||||
oncontextmenu={e => file_menu.open(node, e.target, e)}
|
oncontextmenu={e => file_menu.open(node, e.target, e)}
|
||||||
>
|
>
|
||||||
{#if node.abuse_type !== undefined}
|
{#if node.abuse_type !== undefined}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { loading_finish, loading_start } from "lib/Loading";
|
import { loading_finish, loading_run, loading_start } from "lib/Loading";
|
||||||
import { fs_get_node, fs_encode_path, fs_split_path, fs_path_from_json } from "lib/FilesystemAPI.svelte";
|
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";
|
import type { FSNode, FSPath, FSPermissions, FSContext } from "lib/FilesystemAPI.svelte";
|
||||||
|
|
||||||
export class FSNavigator {
|
export class FSNavigator {
|
||||||
@@ -251,6 +251,84 @@ export class FSNavigator {
|
|||||||
// the reactivity
|
// the reactivity
|
||||||
this.notify_subscribers()
|
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) => {
|
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)
|
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 }) => {
|
export const path_link = (node: HTMLAnchorElement, data: { nav: FSNavigator, node: FSNode }) => {
|
||||||
// Set url
|
// Set url
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte"
|
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";
|
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -24,11 +24,13 @@ let {
|
|||||||
href={"/d"+fs_encode_path(child.path)}
|
href={"/d"+fs_encode_path(child.path)}
|
||||||
onclick={e => file_event(FileAction.Click, index, e)}
|
onclick={e => file_event(FileAction.Click, index, e)}
|
||||||
oncontextmenu={e => file_event(FileAction.Context, 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"
|
||||||
class:node_selected={child.fm_selected}
|
class:node_selected={$nav.selection.has(child.path)}
|
||||||
class:hidden={child.name.startsWith(".") && !show_hidden}
|
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">
|
<div class="node_name">
|
||||||
{child.name}
|
{child.name}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const scroll_positions = new Map<string, number>()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<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 CreateDirectory from "./CreateDirectory.svelte"
|
||||||
import ListView from "./ListView.svelte"
|
import ListView from "./ListView.svelte"
|
||||||
import GalleryView from "./GalleryView.svelte"
|
import GalleryView from "./GalleryView.svelte"
|
||||||
@@ -13,14 +13,13 @@ import CompactView from "./CompactView.svelte"
|
|||||||
import Button from "layout/Button.svelte";
|
import Button from "layout/Button.svelte";
|
||||||
import { formatDate } from "util/Formatting";
|
import { formatDate } from "util/Formatting";
|
||||||
import { drop_target } from "lib/DropTarget"
|
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 FsUploadWidget from "filesystem/upload_widget/FSUploadWidget.svelte";
|
||||||
import EditWindow from "filesystem/edit_window/EditWindow.svelte";
|
import EditWindow from "filesystem/edit_window/EditWindow.svelte";
|
||||||
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
||||||
import FileMenu from "./FileMenu.svelte";
|
import FileMenu from "./FileMenu.svelte";
|
||||||
import { loading_finish, loading_start } from "lib/Loading";
|
import { loading_finish, loading_start } from "lib/Loading";
|
||||||
import SearchBar from "../SearchBar.svelte";
|
import SearchBar from "../SearchBar.svelte";
|
||||||
import type { GenericResponse } from "lib/NovaAPI";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { toolbar_store } from "wrap/HeaderStore";
|
import { toolbar_store } from "wrap/HeaderStore";
|
||||||
|
|
||||||
@@ -42,7 +41,9 @@ let {
|
|||||||
|
|
||||||
let large_icons = $state(localStorage.getItem("large_icons") === "true")
|
let large_icons = $state(localStorage.getItem("large_icons") === "true")
|
||||||
let uploader!: FsUploadWidget
|
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 creating_dir = $state(false)
|
||||||
let show_hidden = $state(false)
|
let show_hidden = $state(false)
|
||||||
let file_menu!: FileMenu
|
let file_menu!: FileMenu
|
||||||
@@ -56,6 +57,15 @@ export const upload = (files: File[]) => {
|
|||||||
|
|
||||||
// Navigation functions
|
// Navigation functions
|
||||||
const file_event: FileActionHandler = (action: FileAction, index: number, orig: Event) => {
|
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.preventDefault()
|
||||||
orig.stopPropagation()
|
orig.stopPropagation()
|
||||||
|
|
||||||
@@ -63,42 +73,34 @@ const file_event: FileActionHandler = (action: FileAction, index: number, orig:
|
|||||||
case FileAction.Click:
|
case FileAction.Click:
|
||||||
creating_dir = false
|
creating_dir = false
|
||||||
|
|
||||||
if (mode === "viewing") {
|
if (!nav.selecting) {
|
||||||
nav.navigate(nav.children[index].path, true)
|
nav.navigate(node.path, true)
|
||||||
} else if (mode === "moving") {
|
} 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
|
// If we are moving files we can only enter directories, and only if
|
||||||
// they're not selected. That last requirement prevents people from
|
// they're not selected. That last requirement prevents people from
|
||||||
// moving a directory into itself
|
// moving a directory into itself
|
||||||
if (nav.children[index].type === "dir" && !nav.children[index].fm_selected) {
|
nav.navigate(node.path, true)
|
||||||
nav.navigate(nav.children[index].path, true)
|
|
||||||
}
|
|
||||||
} else if (mode === "selecting") {
|
|
||||||
select_node(index)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case FileAction.Context:
|
case FileAction.Context:
|
||||||
// If this is a touch event we will select the item
|
// If this is a touch event we will select the item
|
||||||
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
|
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
|
||||||
select_node(index)
|
nav.select(node)
|
||||||
} else {
|
} else {
|
||||||
file_menu.open(nav.children[index], orig.target, orig)
|
file_menu.open(node, orig.target, orig)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case FileAction.Edit:
|
|
||||||
edit_window.edit(nav.children[index], false, "file")
|
|
||||||
break
|
|
||||||
case FileAction.Share:
|
case FileAction.Share:
|
||||||
creating_dir = false
|
creating_dir = false
|
||||||
edit_window.edit(nav.children[index], false, "share")
|
edit_window.edit(node, false, "share")
|
||||||
break
|
break
|
||||||
case FileAction.Branding:
|
case FileAction.Branding:
|
||||||
edit_window.edit(nav.children[index], false, "branding")
|
edit_window.edit(node, false, "branding")
|
||||||
break
|
|
||||||
case FileAction.Select:
|
|
||||||
select_node(index)
|
|
||||||
break
|
break
|
||||||
case FileAction.Menu:
|
case FileAction.Menu:
|
||||||
file_menu.open(nav.children[index], orig.target, orig)
|
file_menu.open(node, orig.target, orig)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,45 +116,17 @@ const delete_selected = async () => {
|
|||||||
try {
|
try {
|
||||||
loading_start()
|
loading_start()
|
||||||
|
|
||||||
// Save all promises with deletion requests in an array
|
await Promise.all([...nav.selection.values()].map(node => fs_trash(node.path)))
|
||||||
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)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err)
|
console.log(err)
|
||||||
alert("Delete failed: " + err.message + " ("+err.value+")")
|
alert("Delete failed: " + err.message + " ("+err.value+")")
|
||||||
} finally {
|
} finally {
|
||||||
viewing_mode()
|
nav.set_selecting(false)
|
||||||
loading_finish()
|
loading_finish()
|
||||||
await nav.reload()
|
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 = () => {
|
const toggle_view = () => {
|
||||||
if (directory_view === "list") {
|
if (directory_view === "list") {
|
||||||
directory_view = "gallery"
|
directory_view = "gallery"
|
||||||
@@ -169,14 +143,7 @@ const toggle_large_icons = () => {
|
|||||||
localStorage.setItem("large_icons", JSON.stringify(large_icons))
|
localStorage.setItem("large_icons", JSON.stringify(large_icons))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moving functions
|
const keydown = (e: KeyboardEvent) => {
|
||||||
|
|
||||||
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) => {
|
|
||||||
if (
|
if (
|
||||||
(document.activeElement as any).type !== undefined &&
|
(document.activeElement as any).type !== undefined &&
|
||||||
(document.activeElement as any).type === "text"
|
(document.activeElement as any).type === "text"
|
||||||
@@ -184,102 +151,32 @@ const keypress = (e: KeyboardEvent) => {
|
|||||||
return // Prevent shortcuts from interfering with input fields
|
return // Prevent shortcuts from interfering with input fields
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === "Shift") {
|
if (e.key === "a" && e.ctrlKey) {
|
||||||
shift_pressed = e.type === "keydown"
|
// CTRL + A selects all files which are visible
|
||||||
} else if (e.type === "keydown" && e.key === "a" && e.ctrlKey) {
|
for (const child of nav.children) {
|
||||||
// CTRL + A selects all files
|
if (show_hidden || !child.is_hidden()) {
|
||||||
selecting_mode()
|
nav.selection.set(child.path, child)
|
||||||
for (let i = 0; i < nav.children.length; i++) {
|
|
||||||
nav.children[i].fm_selected = true
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
nav.set_selecting(true)
|
||||||
e.preventDefault()
|
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
|
// When escape is pressed we return to viewing mode
|
||||||
viewing_mode()
|
nav.set_selecting(false)
|
||||||
creating_dir = false
|
creating_dir = false
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const select_node = (index: number) => {
|
// Runs every time the navigator changes
|
||||||
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
|
|
||||||
$effect.pre(() => {
|
$effect.pre(() => {
|
||||||
|
if (!$nav.selecting) {
|
||||||
|
moving = false
|
||||||
|
}
|
||||||
creating_dir = 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.
|
// The toolbar is shown in the bar at the top of the page, under the breadcrumbs.
|
||||||
// Other pages don't have it
|
// Other pages don't have it
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -289,27 +186,30 @@ onMount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
onkeydown={keypress}
|
onkeydown={keydown}
|
||||||
onkeyup={keypress}
|
|
||||||
onscroll={() => scroll_positions.set(nav.base.path, window.scrollY)}
|
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()}
|
{#snippet toolbar()}
|
||||||
{#if mode === "viewing"}
|
{#if !$nav.selecting}
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar_left">
|
<div class="toolbar_left">
|
||||||
<button onclick={() => {search_bar.open()}} title="Press / to search">
|
<button onclick={() => {search_bar.open()}} title="Press / to search">
|
||||||
<i class="icon">search</i>
|
<i class="icon">search</i>
|
||||||
</button>
|
</button>
|
||||||
<button onclick={navigate_back} title="Back">
|
{@render nav_buttons()}
|
||||||
<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 $nav.children.some(c => c.name === ".Trash" && c.type === "dir")}
|
{#if $nav.children.some(c => c.name === ".Trash" && c.type === "dir")}
|
||||||
<button
|
<button
|
||||||
onclick={() => nav.navigate($nav.children.find(c => c.name === ".Trash").path, true)}
|
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 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>
|
<i class="icon">select_all</i>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if mode === "selecting"}
|
{:else}
|
||||||
<div class="toolbar toolbar_edit">
|
<div class="toolbar toolbar_edit">
|
||||||
<Button click={viewing_mode} icon="close"/>
|
<div>
|
||||||
<div class="toolbar_spacer">Selecting files</div>
|
<Button click={() => nav.set_selecting(false)} icon="close"/>
|
||||||
<Button click={move_start} icon="drive_file_move" label="Move"/>
|
{@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">
|
<button onclick={delete_selected} class="button_red">
|
||||||
<i class="icon">delete</i>
|
<i class="icon">delete</i>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</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={() => {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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -388,6 +296,7 @@ onMount(() => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="container"
|
class="container"
|
||||||
|
use:move_target={{nav, node: () => nav.base}}
|
||||||
use:drop_target={{
|
use:drop_target={{
|
||||||
upload: (files) => upload_widget.upload_files(files),
|
upload: (files) => upload_widget.upload_files(files),
|
||||||
shadow: "var(--accent_color) 0 0 10px 2px inset",
|
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 {
|
export enum FileAction {
|
||||||
Click,
|
Click,
|
||||||
Context,
|
Context,
|
||||||
Edit,
|
|
||||||
Share,
|
Share,
|
||||||
Branding,
|
Branding,
|
||||||
Select,
|
|
||||||
Download,
|
|
||||||
Menu,
|
Menu,
|
||||||
|
Drag,
|
||||||
}
|
}
|
||||||
export type FileActionHandler = (
|
export type FileActionHandler = (
|
||||||
action: FileAction,
|
action: FileAction,
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ const delete_node = async () => {
|
|||||||
<Button click={() => {dialog.close(); bookmark_add(node)}} icon="bookmark_add" label="Add bookmark"/>
|
<Button click={() => {dialog.close(); bookmark_add(node)}} icon="bookmark_add" label="Add bookmark"/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $nav.permissions.write}
|
{#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"/>
|
<Button click={() => {dialog.close(); delete_node()}} icon="delete" label="Delete"/>
|
||||||
{#if edit_window !== null}
|
{#if edit_window !== null}
|
||||||
<Button click={() => {dialog.close(); edit_window.edit(node, false, "file")}} icon="edit" label="Edit"/>
|
<Button click={() => {dialog.close(); edit_window.edit(node, false, "file")}} icon="edit" label="Edit"/>
|
||||||
|
|||||||
@@ -24,16 +24,10 @@ let {
|
|||||||
|
|
||||||
export const open = (path: string) => {
|
export const open = (path: string) => {
|
||||||
modal.show()
|
modal.show()
|
||||||
|
nav.set_selecting(false)
|
||||||
nav.navigate(path, false)
|
nav.navigate(path, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
let selected_files = $derived($nav.children.reduce((acc, file) => {
|
|
||||||
if (file.fm_selected) {
|
|
||||||
acc++
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, 0))
|
|
||||||
|
|
||||||
// Navigation functions
|
// Navigation functions
|
||||||
|
|
||||||
const file_event: FileActionHandler = (action: FileAction, index: number, orig: Event) => {
|
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") {
|
if (nav.children[index].type === "dir") {
|
||||||
nav.navigate(nav.children[index].path, true)
|
nav.navigate(nav.children[index].path, true)
|
||||||
} else {
|
} else {
|
||||||
select_node(index)
|
select_node(index, orig)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case FileAction.Context:
|
case FileAction.Context:
|
||||||
// If this is a touch event we will select the item
|
// If this is a touch event we will select the item
|
||||||
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
|
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
|
||||||
orig.preventDefault()
|
orig.preventDefault()
|
||||||
select_node(index)
|
select_node(index, orig)
|
||||||
}
|
}
|
||||||
break
|
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)
|
localStorage.setItem("directory_view", directory_view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to detect if shift is pressed so we can select multiple items
|
const select_node = (index: number, e: Event) => {
|
||||||
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) => {
|
|
||||||
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) {
|
if (!select_multiple) {
|
||||||
for (let i = 0; i < nav.children.length; i++) {
|
nav.selection.clear()
|
||||||
nav.children[i].fm_selected = false
|
|
||||||
}
|
}
|
||||||
}
|
nav.select(nav.children[index], select_multiple && (e as MouseEvent).shiftKey, show_hidden)
|
||||||
nav.children[index].fm_selected = !nav.children[index].fm_selected
|
|
||||||
}
|
|
||||||
|
|
||||||
last_selected_node = index
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let done = () => {
|
let done = () => {
|
||||||
let selected_files: FSNode[] = []
|
if (nav.selection.size > 0) {
|
||||||
for (let i = 0; i < nav.children.length; i++) {
|
callback([...nav.selection.values()])
|
||||||
if (nav.children[i].fm_selected) {
|
|
||||||
selected_files.push(nav.children[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected_files.length > 0) {
|
|
||||||
callback(selected_files)
|
|
||||||
}
|
}
|
||||||
modal.hide()
|
modal.hide()
|
||||||
}
|
}
|
||||||
@@ -131,8 +87,6 @@ onMount(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={detect_shift} onkeyup={detect_shift} />
|
|
||||||
|
|
||||||
<Modal bind:this={modal} width="900px">
|
<Modal bind:this={modal} width="900px">
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<div class="header" >
|
<div class="header" >
|
||||||
@@ -147,7 +101,7 @@ onMount(() => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="title">
|
<div class="title">
|
||||||
Selected {selected_files} files
|
Selected {$nav.selection.size} files
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onclick={() => {show_hidden = !show_hidden}} title="Toggle hidden files">
|
<button onclick={() => {show_hidden = !show_hidden}} title="Toggle hidden files">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fs_node_icon, fs_node_type, fs_encode_path } from "lib/FilesystemAPI.svelte";
|
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";
|
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -22,7 +22,9 @@ let {
|
|||||||
href={"/d"+fs_encode_path(child.path)}
|
href={"/d"+fs_encode_path(child.path)}
|
||||||
onclick={e => file_event(FileAction.Click, index, e)}
|
onclick={e => file_event(FileAction.Click, index, e)}
|
||||||
oncontextmenu={e => file_event(FileAction.Context, 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:hidden={child.name.startsWith(".") && !show_hidden}
|
||||||
class:large_icons
|
class:large_icons
|
||||||
title={child.name}
|
title={child.name}
|
||||||
@@ -33,6 +35,7 @@ let {
|
|||||||
src={fs_node_icon(child, 256, 256)}
|
src={fs_node_icon(child, 256, 256)}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
alt=""
|
alt=""
|
||||||
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
<div class="node_name">
|
<div class="node_name">
|
||||||
{child.name}
|
{child.name}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatDataVolume, formatDate, formatThousands } from "util/Formatting";
|
import { formatDataVolume, formatDate, formatThousands } from "util/Formatting";
|
||||||
import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte"
|
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 SortButton from "layout/SortButton.svelte";
|
||||||
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
||||||
import Dialog from "layout/Dialog.svelte";
|
import Dialog from "layout/Dialog.svelte";
|
||||||
@@ -15,7 +15,8 @@ let {
|
|||||||
show_hidden = false,
|
show_hidden = false,
|
||||||
large_icons = false,
|
large_icons = false,
|
||||||
hide_edit = false,
|
hide_edit = false,
|
||||||
hide_branding = false
|
hide_branding = false,
|
||||||
|
trash = false
|
||||||
}: {
|
}: {
|
||||||
nav: FSNavigator
|
nav: FSNavigator
|
||||||
file_event: FileActionHandler
|
file_event: FileActionHandler
|
||||||
@@ -23,6 +24,7 @@ let {
|
|||||||
large_icons?: boolean
|
large_icons?: boolean
|
||||||
hide_edit?: boolean
|
hide_edit?: boolean
|
||||||
hide_branding?: boolean
|
hide_branding?: boolean
|
||||||
|
trash?: boolean
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let dialog: Dialog = $state()
|
let dialog: Dialog = $state()
|
||||||
@@ -78,6 +80,18 @@ onMount(load_settings)
|
|||||||
Name
|
Name
|
||||||
</SortButton>
|
</SortButton>
|
||||||
</td>
|
</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}
|
{#each columns as col}
|
||||||
<td class="hide_small" class:hidden={!config[col.field]}>
|
<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}>
|
<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
|
<tr
|
||||||
onclick={e => file_event(FileAction.Click, index, e)}
|
onclick={e => file_event(FileAction.Click, index, e)}
|
||||||
oncontextmenu={e => file_event(FileAction.Context, 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"
|
||||||
class:node_selected={child.fm_selected}
|
class:node_selected={$nav.selection.has(child.path)}
|
||||||
class:hidden={child.name.startsWith(".") && !show_hidden}
|
class:hidden={child.name.startsWith(".") && !show_hidden}
|
||||||
>
|
>
|
||||||
<td>
|
<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>
|
||||||
<td class="node_name">
|
<td class="node_name" class:half={trash}>
|
||||||
<a class="title_link" href={"/d"+fs_encode_path(child.path)}>
|
<a class="title_link" href={"/d"+fs_encode_path(child.path)} draggable="false">
|
||||||
{child.name}
|
{child.name}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</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}>
|
<td class="node_size hide_small" class:hidden={!config.modified}>
|
||||||
{formatDate(child.modified, true, true, false)}
|
{formatDate(child.modified, true, true, false)}
|
||||||
</td>
|
</td>
|
||||||
@@ -213,8 +236,8 @@ td {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.node.node_selected {
|
.node.node_selected {
|
||||||
background-color: var(--accent_color) !important;
|
background-color: var(--accent_shade) !important;
|
||||||
color: var(--accent_text_color);
|
color: var(--link_color);
|
||||||
}
|
}
|
||||||
td {
|
td {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
@@ -231,9 +254,13 @@ td {
|
|||||||
line-height: 1.2em;
|
line-height: 1.2em;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
/* In the trash the name shares its width with the original location */
|
||||||
|
.half {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
.title_link {
|
.title_link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--body_text_color);
|
color: unset;
|
||||||
}
|
}
|
||||||
.node_size {
|
.node_size {
|
||||||
min-width: 5em;
|
min-width: 5em;
|
||||||
@@ -265,5 +292,8 @@ td {
|
|||||||
.hide_small {
|
.hide_small {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.half {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatDataVolume, formatDate } from "util/Formatting";
|
import { formatDataVolume } 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 { fs_check_response, fs_get_trash, fs_path_url, fs_rename, fs_trash, type FSNode } from "lib/FilesystemAPI.svelte";
|
||||||
import { loading_finish, loading_run, loading_start, loading_store } from "lib/Loading";
|
import { loading_run } from "lib/Loading";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { breadcrumbs_store } from "wrap/HeaderStore";
|
import { toolbar_store } from "wrap/HeaderStore";
|
||||||
import { FSNavigator } from "filesystem/FSNavigator";
|
import { FSNavigator } from "filesystem/FSNavigator";
|
||||||
|
import Button from "layout/Button.svelte";
|
||||||
|
import ListView from "./ListView.svelte";
|
||||||
|
import { FileAction, type FileActionHandler } from "./FileManagerLib";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
nav,
|
nav,
|
||||||
@@ -14,214 +17,157 @@ let {
|
|||||||
root_path?: string
|
root_path?: string
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let entries: TrashEntry[] = $state([]);
|
// The trash entries are not the children of the .Trash directory, so they get a
|
||||||
let error: string = $state("");
|
// navigator of their own for the ListView to list and sort
|
||||||
|
const trash = new FSNavigator(false)
|
||||||
|
|
||||||
const load = async () => {
|
let size = $derived($trash.children.reduce(
|
||||||
loading_run(async () => {
|
(acc, node) => acc + (node.type === "dir" ? node.recursive_size : node.file_size), 0
|
||||||
|
))
|
||||||
|
|
||||||
|
const load = () => loading_run(async () => {
|
||||||
try {
|
try {
|
||||||
entries = await fs_get_trash(root_path)
|
trash.children = await fs_get_trash(root_path)
|
||||||
error = "";
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error = err?.message ?? JSON.stringify(err);
|
alert("Failed to load the trash bin: " + (err.message ?? err))
|
||||||
}
|
}
|
||||||
|
trash.selecting = false
|
||||||
|
trash.selection.clear()
|
||||||
|
trash.sort_children("")
|
||||||
})
|
})
|
||||||
};
|
|
||||||
|
|
||||||
const delete_entry = async (entry: TrashEntry) => {
|
const file_event: FileActionHandler = (action, index, e) => {
|
||||||
if (!confirm(`Permanently delete "${entry.node.name}"?`)) return;
|
if (action === FileAction.Drag) {
|
||||||
try {
|
return
|
||||||
loading_start();
|
} else if (action === FileAction.Context) {
|
||||||
await fs_check_response(
|
// On a touch screen a long press starts selecting, like in the file
|
||||||
await fetch(fs_path_url(entry.node.path) + "?trash", { method: "DELETE" })
|
// manager. A right click is left to the browser's own menu
|
||||||
);
|
if (!navigator.maxTouchPoints) {
|
||||||
entries = entries.filter(e => e.node.id !== entry.node.id);
|
return
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.message ?? JSON.stringify(err));
|
|
||||||
} finally {
|
|
||||||
loading_finish();
|
|
||||||
}
|
}
|
||||||
};
|
trash.selecting = true
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
const restore_entry = async (entry: TrashEntry) => {
|
if (trash.selecting) {
|
||||||
if (!entry.original_path) {
|
trash.select(trash.children[index], (e as MouseEvent).shiftKey)
|
||||||
alert("Original path is not known, cannot restore this file.");
|
} else {
|
||||||
return;
|
nav.navigate(trash.children[index].path, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const empty_trash = async () => {
|
const empty_trash = async () => {
|
||||||
if (!confirm("Permanently delete all items in the trash?")) return;
|
if (!confirm("Permanently delete all items in the trash?")) {
|
||||||
try {
|
return
|
||||||
loading_start();
|
}
|
||||||
await fs_check_response(
|
try {
|
||||||
await fetch(fs_path_url(root_path) + "?empty_trash", { method: "DELETE" })
|
await loading_run(async () => fs_check_response(
|
||||||
);
|
await fetch(fs_path_url(root_path) + "?empty_trash", { method: "DELETE" })
|
||||||
entries = [];
|
))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err?.message ?? JSON.stringify(err));
|
alert(err.message ?? err)
|
||||||
} finally {
|
}
|
||||||
loading_finish();
|
await load()
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
breadcrumbs_store.set(breadcrumbs)
|
toolbar_store.set(toolbar)
|
||||||
|
|
||||||
load()
|
load()
|
||||||
});
|
return () => toolbar_store.set(null)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet toolbar()}
|
||||||
{#snippet breadcrumbs()}
|
{#if $trash.selecting}
|
||||||
<i class="icon">delete</i>
|
<div class="toolbar toolbar_edit">
|
||||||
<div>
|
<Button icon="close" click={() => trash.set_selecting(false)}/>
|
||||||
Trash bin
|
<div class="toolbar_spacer">Selecting files</div>
|
||||||
{#if $loading_store === 0 && entries.length === 0}
|
<Button click={restore} icon="restore_from_trash" label="Restore"/>
|
||||||
(empty)
|
<button onclick={delete_selected} class="button_red">
|
||||||
{:else if entries.length !== 0}
|
<i class="icon">delete_forever</i>
|
||||||
({formatDataVolume(entries.reduce(
|
Delete
|
||||||
(acc, entry) => acc + entry.node.file_size, 0
|
</button>
|
||||||
), 3)})
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="separator"></div>
|
{:else}
|
||||||
<button onclick={() => window.history.back()} title="Back">
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<button onclick={() => history.back()} title="Back">
|
||||||
<i class="icon">arrow_back</i>
|
<i class="icon">arrow_back</i>
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => nav.navigate_up()} disabled={$nav.path.length <= 1} title="Up">
|
<button onclick={() => nav.navigate_up()} title="Up">
|
||||||
<i class="icon">north</i>
|
<i class="icon">north</i>
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => nav.reload()} title="Refresh directory listing">
|
<button onclick={load} title="Refresh trash bin">
|
||||||
<i class="icon">refresh</i>
|
<i class="icon">refresh</i>
|
||||||
</button>
|
</button>
|
||||||
{#if entries.length !== 0}
|
</div>
|
||||||
<button class="button button_red" onclick={empty_trash}>
|
<div>Trash bin ({formatDataVolume(size, 3)})</div>
|
||||||
<i class="icon">delete_forever</i>
|
<div>
|
||||||
<span>Delete all</span>
|
{#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>
|
||||||
|
<button onclick={empty_trash} class="button_red">
|
||||||
|
<i class="icon">delete_forever</i>
|
||||||
|
Delete all
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if error}
|
<ListView nav={trash} file_event={file_event} show_hidden hide_edit hide_branding trash/>
|
||||||
<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}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.separator {
|
/* Same as the toolbar of the file manager */
|
||||||
flex: 1 1 auto;
|
.toolbar {
|
||||||
}
|
display: flex;
|
||||||
.error {
|
flex-direction: row;
|
||||||
color: var(--red_color);
|
flex-wrap: wrap;
|
||||||
}
|
justify-content: space-between;
|
||||||
.trash_table {
|
align-items: center;
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
background: var(--body_background);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.trash_table td {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.entry {
|
|
||||||
cursor: default;
|
|
||||||
border-top: 1px solid var(--separator);
|
border-top: 1px solid var(--separator);
|
||||||
}
|
}
|
||||||
.entry:hover {
|
.toolbar > * {
|
||||||
background: var(--input_hover_background);
|
flex: 0 0 content;
|
||||||
}
|
|
||||||
.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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25em;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
.toolbar_spacer {
|
||||||
.hide_small {
|
flex: 1 1 auto;
|
||||||
display: none;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.toolbar_edit {
|
||||||
|
background-color: var(--green_tint);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ export type Bookmark = {
|
|||||||
label: string,
|
label: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export let bookmarks_failed = writable(false)
|
||||||
|
|
||||||
export let bookmarks_store = writable<Bookmark[]>(
|
export let bookmarks_store = writable<Bookmark[]>(
|
||||||
[],
|
[],
|
||||||
(set: (value: Bookmark[]) => void) => {
|
(set: (value: Bookmark[]) => void) => {
|
||||||
bookmarks_get().then((bm: Bookmark[]) => {
|
bookmarks_get().then((bm: Bookmark[]) => {
|
||||||
set(bm)
|
set(bm)
|
||||||
}).catch((err: any) => {
|
}).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 }
|
user_permissions?: { [index: string]: FSPermissions }
|
||||||
password_permissions?: { [index: string]: FSPermissions }
|
password_permissions?: { [index: string]: FSPermissions }
|
||||||
|
|
||||||
// Added by us
|
// Only set on the nodes returned by fs_get_trash
|
||||||
|
original_path?: string
|
||||||
// Indicates whether the file is selected in the file manager
|
deletion_date?: string
|
||||||
fm_selected?: boolean = $state(false)
|
|
||||||
|
|
||||||
is_shared = (): boolean => {
|
is_shared = (): boolean => {
|
||||||
return (this.link_permissions !== undefined && this.link_permissions.read === true) ||
|
return (this.link_permissions !== undefined && this.link_permissions.read === true) ||
|
||||||
@@ -251,22 +250,15 @@ export const fs_trash = async (path: string) => {
|
|||||||
) as GenericResponse
|
) as GenericResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TrashEntry = {
|
|
||||||
node: FSNode;
|
|
||||||
original_path: string;
|
|
||||||
deletion_date: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fs_get_trash = async (path: 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")
|
await fetch(fs_path_url(path) + "?trash")
|
||||||
) as TrashEntry[]
|
) as { node: FSNode, original_path: string, deletion_date: string }[]
|
||||||
resp.forEach((entry, index) => {
|
|
||||||
resp[index].node = Object.assign(new FSNode(), entry.node)
|
|
||||||
})
|
|
||||||
|
|
||||||
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) => {
|
export const fs_search = async (path: string, term: string, limit = 10) => {
|
||||||
return await fs_check_response(
|
return await fs_check_response(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<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 { 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 { highlight_current_page } from "lib/HighlightCurrentPage";
|
||||||
import MenuEntry from "./MenuEntry.svelte";
|
import MenuEntry from "./MenuEntry.svelte";
|
||||||
import { flip } from "svelte/animate";
|
import { flip } from "svelte/animate";
|
||||||
@@ -94,7 +95,8 @@ const drop = (e: DragEvent, drop_idx: number) => {
|
|||||||
ondragstart={e => drag(e, index)}
|
ondragstart={e => drag(e, index)}
|
||||||
ondrop={e => drop(e, index)}
|
ondrop={e => drop(e, index)}
|
||||||
ondragover={e => {e.preventDefault(); e.stopPropagation()}}
|
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}}
|
ondragend={() => {hovering = -1}}
|
||||||
class:highlight={hovering === index}
|
class:highlight={hovering === index}
|
||||||
animate:flip={{duration: 300}}
|
animate:flip={{duration: 300}}
|
||||||
@@ -122,6 +124,8 @@ const drop = (e: DragEvent, drop_idx: number) => {
|
|||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</MenuEntry>
|
</MenuEntry>
|
||||||
|
{:else if $bookmarks_failed}
|
||||||
|
<div>Could not load bookmarks</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { fs_encode_path, fs_node_icon, FSNode } from "lib/FilesystemAPI.svelte";
|
||||||
import { highlight_current_page } from "lib/HighlightCurrentPage";
|
import { highlight_current_page } from "lib/HighlightCurrentPage";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
@@ -39,7 +39,7 @@ onMount(() => {
|
|||||||
{#snippet body()}
|
{#snippet body()}
|
||||||
{#each $global_navigator.path.slice(0, $global_navigator.path.length-1) as node (node.id)}
|
{#each $global_navigator.path.slice(0, $global_navigator.path.length-1) as node (node.id)}
|
||||||
{#if node.type === "dir"}
|
{#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}">
|
<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}"/>
|
<img class="thumbnail" src="{fs_node_icon(node, 32, 32)}" alt="{node.name}"/>
|
||||||
<span>{node.name}</span>
|
<span>{node.name}</span>
|
||||||
@@ -64,7 +64,7 @@ onMount(() => {
|
|||||||
{#snippet body()}
|
{#snippet body()}
|
||||||
{#each siblings as node (node.id)}
|
{#each siblings as node (node.id)}
|
||||||
{#if !node.is_hidden()}
|
{#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>
|
<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}"/>
|
<img class="thumbnail" src="{fs_node_icon(node, 32, 32)}" alt="{node.name}"/>
|
||||||
<span>{node.name}</span>
|
<span>{node.name}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user