Make filesystem list view columns sortable

This commit is contained in:
2025-04-02 15:12:38 +02:00
parent 1d72179672
commit 4b297fec46
11 changed files with 106 additions and 50 deletions

View File

@@ -2,7 +2,7 @@
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import { formatDate } from "util/Formatting"; import { formatDate } from "util/Formatting";
import Modal from "util/Modal.svelte" import Modal from "util/Modal.svelte"
import SortButton from "./SortButton.svelte"; import SortButton from "layout/SortButton.svelte";
import { flip } from "svelte/animate"; import { flip } from "svelte/animate";
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View File

@@ -1,7 +1,7 @@
<script> <script>
import { flip } from "svelte/animate"; import { flip } from "svelte/animate";
import { formatDataVolume } from "util/Formatting"; import { formatDataVolume } from "util/Formatting";
import SortButton from "./SortButton.svelte"; import SortButton from "layout/SortButton.svelte";
export let peers = []; export let peers = [];
$: update_peers(peers) $: update_peers(peers)

View File

@@ -2,7 +2,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import LoadingIndicator from "util/LoadingIndicator.svelte"; import LoadingIndicator from "util/LoadingIndicator.svelte";
import { formatDataVolume, formatDate } from "util/Formatting"; import { formatDataVolume, formatDate } from "util/Formatting";
import SortButton from "admin_panel/SortButton.svelte"; import SortButton from "layout/SortButton.svelte";
export let user_id = "" export let user_id = ""
let files = [] let files = []

View File

@@ -39,7 +39,7 @@ export class FSNavigator {
// If you set the loading property to a boolean writable store the navigator // If you set the loading property to a boolean writable store the navigator
// will use it to publish its loading states // will use it to publish its loading states
loading: Writable<boolean> | null = null loading: Writable<boolean> | null = null
set_loading(b: boolean) { set_loading = (b: boolean) => {
if (this.loading !== null) { if (this.loading !== null) {
this.loading.set(b) this.loading.set(b)
} }
@@ -49,7 +49,7 @@ export class FSNavigator {
// This works by implementing the store contract: // This works by implementing the store contract:
// https://svelte.dev/docs/svelte-components#script-4-prefix-stores-with-$-to-access-their-values // https://svelte.dev/docs/svelte-components#script-4-prefix-stores-with-$-to-access-their-values
subscribers: Array<(nav: FSNavigator) => void> = [] subscribers: Array<(nav: FSNavigator) => void> = []
subscribe(sub_func: (nav: FSNavigator) => void) { subscribe = (sub_func: (nav: FSNavigator) => void) => {
// Immediately return the current value // Immediately return the current value
sub_func(this) sub_func(this)
@@ -58,8 +58,13 @@ export class FSNavigator {
// Return the unsubscribe function // Return the unsubscribe function
return () => this.subscribers.splice(this.subscribers.indexOf(sub_func), 1) return () => this.subscribers.splice(this.subscribers.indexOf(sub_func), 1)
} }
notify_subscribers = () => {
for (let i = 0; i < this.subscribers.length; i++) {
this.subscribers[i](this)
}
}
async navigate(path: string, push_history: boolean) { navigate = async (path: string, push_history: boolean) => {
if (path[0] !== "/") { if (path[0] !== "/") {
path = "/" + path path = "/" + path
} }
@@ -88,17 +93,17 @@ export class FSNavigator {
} }
} }
async navigate_up() { navigate_up = async () => {
if (this.path.length > 1) { if (this.path.length > 1) {
await this.navigate(this.path[this.path.length - 2].path, false) await this.navigate(this.path[this.path.length - 2].path, false)
} }
} }
async reload() { reload = async () => {
await this.navigate(this.base.path, false) await this.navigate(this.base.path, false)
} }
open_node(node: FSPath, push_history: boolean) { open_node = (node: FSPath, push_history: boolean) => {
// Update window title and navigation history. If push_history is false // Update window title and navigation history. If push_history is false
// we still replace the URL with replaceState. This way the user is not // we still replace the URL with replaceState. This way the user is not
// greeted to a 404 page when refreshing after renaming a file // greeted to a 404 page when refreshing after renaming a file
@@ -122,7 +127,7 @@ export class FSNavigator {
} }
// Sort directory children // Sort directory children
sort_children(node.children) sort_children(node.children, this.sort_last_field, this.sort_asc)
// Update shared state // Update shared state
this.path = node.path this.path = node.path
@@ -137,9 +142,7 @@ export class FSNavigator {
// Signal to our subscribers that the new node is loaded. This triggers // Signal to our subscribers that the new node is loaded. This triggers
// the reactivity // the reactivity
for (let i = 0; i < this.subscribers.length; i++) { this.notify_subscribers()
this.subscribers[i](this)
}
} }
// These are used to navigate forward and backward within a directory (using // These are used to navigate forward and backward within a directory (using
@@ -151,7 +154,7 @@ export class FSNavigator {
cached_siblings_path = "" cached_siblings_path = ""
cached_siblings: Array<FSNode> | null = null cached_siblings: Array<FSNode> | null = null
async get_siblings() { get_siblings = async () => {
// If this node is a filesystem root then there are no siblings // If this node is a filesystem root then there are no siblings
if (this.path.length < 2) { if (this.path.length < 2) {
return [] return []
@@ -166,7 +169,7 @@ export class FSNavigator {
const resp = await fs_get_node(this.path[this.path.length - 2].path) const resp = await fs_get_node(this.path[this.path.length - 2].path)
// Sort directory children to make sure the order is consistent // Sort directory children to make sure the order is consistent
sort_children(resp.children) sort_children(resp.children, this.sort_last_field, this.sort_asc)
// Save new siblings in navigator state // Save new siblings in navigator state
this.cached_siblings_path = this.path[this.path.length - 2].path this.cached_siblings_path = this.path[this.path.length - 2].path
@@ -179,7 +182,7 @@ export class FSNavigator {
// Opens a sibling of the currently open file. The offset is relative to the // Opens a sibling of the currently open file. The offset is relative to the
// file which is currently open. Give a positive number to move forward and // file which is currently open. Give a positive number to move forward and
// a negative number to move backward // a negative number to move backward
async open_sibling(offset: number) { open_sibling = async (offset: number) => {
if (this.path.length <= 1) { if (this.path.length <= 1) {
return return
} }
@@ -233,14 +236,51 @@ export class FSNavigator {
console.debug("No siblings found") console.debug("No siblings found")
} }
} }
sort_last_field: string = "name"
sort_asc: boolean = true
sort_children = (field: string) => {
// If the field is the same as last time we invert the direction
if (field !== "" && field === this.sort_last_field) {
this.sort_asc = !this.sort_asc
}
// If the field is empty we reuse the last field
if (field === "") {
field = this.sort_last_field
}
this.sort_last_field = field
sort_children(this.children, field, this.sort_asc)
// Signal to our subscribers that the order has changed. This triggers
// the reactivity
this.notify_subscribers()
}
} }
const sort_children = (children: Array<FSNode>) => { const sort_children = (children: FSNode[], field: string, asc: boolean) => {
console.log("Sorting directory children by", field, "asc", asc)
children.sort((a, b) => { children.sort((a, b) => {
// Sort directories before files // Sort directories before files
if (a.type !== b.type) { if (a.type !== b.type) {
return a.type === "dir" ? -1 : 1 return a.type === "dir" ? -1 : 1
} }
// If sort is descending we swap the arguments
if (asc === false) {
[a, b] = [b, a]
}
// If the two values are equal then we force sort by name, since names
// are always unique
if (a[field] === b[field]) {
return a.name.localeCompare(b.name, undefined, { numeric: true }) return a.name.localeCompare(b.name, undefined, { numeric: true })
} else if (typeof (a[field]) === "number") {
// Sort ints from high to low
return a[field] - b[field]
} else {
// Sort strings alphabetically
return a[field].localeCompare(b[field], undefined, { numeric: true })
}
}) })
} }

View File

@@ -65,13 +65,6 @@ const update_shared = () => {
</div> </div>
{/if} {/if}
<p>
When a file or directory is shared it can be accessed through a
unique link. You can get the URL with the 'Copy link' button on
the toolbar, or share the link with the 'Share' button. If you
share a directory all the files within the directory are also
accessible from the link.
</p>
<div> <div>
<input <input
form="edit_form" form="edit_form"
@@ -83,13 +76,21 @@ const update_shared = () => {
/> />
<label for="shared">Share this file or directory</label> <label for="shared">Share this file or directory</label>
</div> </div>
<div class="form_grid"> <div class="form_grid">
{#if is_shared} {#if is_shared}
<span>Your sharing link: <a href={share_link}>{share_link}</a></span> <span>Public link: <a href={share_link}>{share_link}</a></span>
<CopyButton text={share_link}>Copy</CopyButton> <CopyButton text={share_link}>Copy</CopyButton>
{/if} {/if}
</div> </div>
<p>
When a file or directory is shared it can be accessed through a
unique link. You can get the URL with the 'Copy link' button on
the toolbar, or share the link with the 'Share' button. If you
share a directory all the files within the directory are also
accessible from the link.
</p>
</fieldset> </fieldset>
<fieldset> <fieldset>

View File

@@ -8,6 +8,7 @@ let dispatch = createEventDispatcher()
export let nav: FSNavigator export let nav: FSNavigator
export let show_hidden = false export let show_hidden = false
export let large_icons = false export let large_icons = false
export let hide_edit = false
</script> </script>
<div class="directory"> <div class="directory">
@@ -28,11 +29,17 @@ export let large_icons = false
<a <a
href="/d/{child.id}" href="/d/{child.id}"
on:click|preventDefault|stopPropagation={e => {dispatch("node_share_click", {index: index, original: e})}} on:click|preventDefault|stopPropagation={e => {dispatch("node_share_click", {index: index, original: e})}}
class="button action_button" class="button flat action_button"
> >
<i class="icon" title="This file / directory is shared. Click to open public link">share</i> <i class="icon" title="This file / directory is shared. Click to open public link">share</i>
</a> </a>
{/if} {/if}
{#if $nav.permissions.write && !hide_edit}
<button class="action_button flat" on:click|preventDefault|stopPropagation={e => dispatch("node_settings", {index: index, original: e})}>
<i class="icon">edit</i>
</button>
{/if}
</a> </a>
{/each} {/each}
</div> </div>
@@ -42,7 +49,7 @@ export let large_icons = false
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(20em, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20em, 1fr));
gap: 8px; gap: 8px;
margin: 8px; padding: 8px;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
} }
@@ -55,6 +62,7 @@ export let large_icons = false
align-items: center; align-items: center;
background: var(--input_background); background: var(--input_background);
border-radius: 4px; border-radius: 4px;
box-shadow: 1px 1px 8px 0px var(--shadow_color);
gap: 6px; gap: 6px;
} }
.node:hover:not(.node_selected) { .node:hover:not(.node_selected) {
@@ -83,6 +91,12 @@ export let large_icons = false
.hidden { .hidden {
display: none; display: none;
} }
.flat {
background: none;
color: var(--body_text_color);
box-shadow: none;
margin: 0;
}
/* Large icon mode only supported on wide screens */ /* Large icon mode only supported on wide screens */
@media (min-width: 500px) { @media (min-width: 500px) {

View File

@@ -66,7 +66,7 @@ let node_context = (e: CustomEvent<FMNodeEvent>) => {
} }
const node_share_click = (e: CustomEvent<FMNodeEvent>) => { const node_share_click = (e: CustomEvent<FMNodeEvent>) => {
creating_dir = false creating_dir = false
nav.navigate(nav.children[e.detail.index].id, true) edit_window.edit(nav.children[e.detail.index], false, "share")
} }
const node_select = (e: CustomEvent<FMNodeEvent>) => { const node_select = (e: CustomEvent<FMNodeEvent>) => {
const index = e.detail.index const index = e.detail.index

View File

@@ -189,6 +189,7 @@ onMount(() => {
nav={nav} nav={nav}
show_hidden={show_hidden} show_hidden={show_hidden}
large_icons={large_icons} large_icons={large_icons}
hide_edit
on:node_click={node_click} on:node_click={node_click}
on:node_context={node_context} on:node_context={node_context}
on:node_select={node_select} on:node_select={node_select}

View File

@@ -3,6 +3,8 @@ import { createEventDispatcher } from "svelte";
import { formatDataVolume } from "util/Formatting"; import { formatDataVolume } from "util/Formatting";
import { fs_encode_path, fs_node_icon } from "filesystem/FilesystemAPI" import { fs_encode_path, fs_node_icon } from "filesystem/FilesystemAPI"
import type { FSNavigator } from "filesystem/FSNavigator"; import type { FSNavigator } from "filesystem/FSNavigator";
import SortButton from "layout/SortButton.svelte";
import { flip } from "svelte/animate";
let dispatch = createEventDispatcher() let dispatch = createEventDispatcher()
@@ -16,8 +18,8 @@ export let hide_branding = false
<div class="directory"> <div class="directory">
<tr> <tr>
<td></td> <td></td>
<td>Name</td> <td><SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="name">Name</SortButton></td>
<td class="hide_small">Size</td> <td><SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="file_size">Size</SortButton></td>
<td></td> <td></td>
</tr> </tr>
{#each $nav.children as child, index (child.path)} {#each $nav.children as child, index (child.path)}
@@ -25,6 +27,7 @@ export let hide_branding = false
href={"/d"+fs_encode_path(child.path)} href={"/d"+fs_encode_path(child.path)}
on:click|preventDefault={e => dispatch("node_click", {index: index, original: e})} on:click|preventDefault={e => dispatch("node_click", {index: index, original: e})}
on:contextmenu={e => dispatch("node_context", {index: index, original: e})} on:contextmenu={e => dispatch("node_context", {index: index, original: e})}
animate:flip={{duration: 500}}
class="node" class="node"
class:node_selected={child.fm_selected} class:node_selected={child.fm_selected}
class:hidden={child.name.startsWith(".") && !show_hidden} class:hidden={child.name.startsWith(".") && !show_hidden}
@@ -53,7 +56,7 @@ export let hide_branding = false
<i class="icon" title="This file / directory is shared. Click to open public link">share</i> <i class="icon" title="This file / directory is shared. Click to open public link">share</i>
</a> </a>
{/if} {/if}
{#if child.properties !== undefined && child.properties.branding_enabled !== undefined && !hide_branding} {#if child.properties !== undefined && child.properties.branding_enabled === "true" && !hide_branding}
<button class="action_button" on:click|preventDefault|stopPropagation={e => dispatch("node_branding", {index: index, original: e})}> <button class="action_button" on:click|preventDefault|stopPropagation={e => dispatch("node_branding", {index: index, original: e})}>
<i class="icon">palette</i> <i class="icon">palette</i>
</button> </button>
@@ -78,7 +81,7 @@ export let hide_branding = false
border-radius: 8px; border-radius: 8px;
max-width: 99%; max-width: 99%;
width: 1000px; width: 1200px;
} }
.directory > * { .directory > * {
display: table-row; display: table-row;
@@ -120,7 +123,7 @@ td {
word-break: break-all; word-break: break-all;
} }
.node_size { .node_size {
min-width: 50px; min-width: 5em;
white-space: nowrap; white-space: nowrap;
} }
.icons_wrap { .icons_wrap {

View File

@@ -1,17 +1,13 @@
<script> <script lang="ts">
export let field = "" export let field = ""
export let active_field = "" export let active_field = ""
export let asc = true export let asc = true
export let sort_func export let sort_func: (field: string) => void
</script> </script>
<button class:button_highlight={active_field === field} on:click={() => sort_func(field)}> <button class:button_highlight={active_field === field} on:click={() => sort_func(field)}>
{#if active_field === field} {#if active_field === field}
{#if asc} {#if asc}{:else}{/if}
{:else}
{/if}
{/if} {/if}
<slot></slot> <slot></slot>
</button> </button>

View File

@@ -9,7 +9,7 @@ import SuccessMessage from "util/SuccessMessage.svelte";
let loading = false let loading = false
let success_message let success_message
let hotlinking = window.user.hotlinking_enabled let hotlinking = window.user.hotlinking_enabled
let transfer_cap = window.user.monthly_transfer_cap / 1e9 let transfer_cap = window.user.monthly_transfer_cap / 1e12
let skip_viewer = window.user.skip_file_viewer let skip_viewer = window.user.skip_file_viewer
const update = async () => { const update = async () => {
@@ -17,7 +17,7 @@ const update = async () => {
const form = new FormData() const form = new FormData()
form.append("hotlinking_enabled", hotlinking) form.append("hotlinking_enabled", hotlinking)
form.append("transfer_cap", transfer_cap*1e9) form.append("transfer_cap", transfer_cap*1e12)
form.append("skip_file_viewer", skip_viewer) form.append("skip_file_viewer", skip_viewer)
try { try {
@@ -31,7 +31,7 @@ const update = async () => {
} }
window.user.hotlinking_enabled = hotlinking window.user.hotlinking_enabled = hotlinking
window.user.monthly_transfer_cap = transfer_cap*1e9 window.user.monthly_transfer_cap = transfer_cap*1e12
success_message.set(true, "Sharing settings updated") success_message.set(true, "Sharing settings updated")
} catch (err) { } catch (err) {
@@ -102,11 +102,12 @@ onMount(() => {
<h2><Pro/>Bill shock limit</h2> <h2><Pro/>Bill shock limit</h2>
<p> <p>
Billshock limit in gigabytes per month (1 TB = 1000 GB). Set to 0 to disable. Billshock limit in terabytes per month (1 TB = 1000 GB). Set to 0 to
disable.
</p> </p>
<form on:submit|preventDefault={update} class="billshock_container"> <form on:submit|preventDefault={update} class="billshock_container">
<input type="number" bind:value={transfer_cap} step="100" min="0"/> <input type="number" bind:value={transfer_cap} step="0.1" min="0" style="width: 5em;"/>
<div style="margin: 0.5em;">GB</div> <div style="margin: 0.5em;">TB</div>
<button type="submit"> <button type="submit">
<i class="icon">save</i> Save <i class="icon">save</i> Save
</button> </button>
@@ -114,9 +115,9 @@ onMount(() => {
<p> <p>
Bandwidth used in the last 30 days: {formatDataVolume(transfer_used, 3)}, Bandwidth used in the last 30 days: {formatDataVolume(transfer_used, 3)},
new limit: {formatDataVolume(transfer_cap*1e9, 3)} new limit: {formatDataVolume(transfer_cap*1e12, 3)}
</p> </p>
<ProgressBar used={transfer_used} total={transfer_cap*1e9}></ProgressBar> <ProgressBar used={transfer_used} total={transfer_cap*1e12}></ProgressBar>
<p> <p>
The billshock limit limits how much bandwidth your account can use in a The billshock limit limits how much bandwidth your account can use in a
30 day window. When this limit is reached hotlinking will be disabled 30 day window. When this limit is reached hotlinking will be disabled