Compare commits

..

12 Commits

50 changed files with 749 additions and 524 deletions

BIN
res/static/img/header.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

BIN
res/static/img/nova.xcf Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

BIN
res/static/img/nova_128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
res/static/img/nova_256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
res/static/img/nova_32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
res/static/img/nova_512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
res/static/img/nova_64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

View File

@@ -119,25 +119,6 @@ pre>code {
/* Page layout elements */ /* Page layout elements */
.button_toggle_navigation {
position: fixed;
backface-visibility: hidden;
z-index: 10;
top: 0;
left: 0;
padding: 8px 16px 16px 8px;
font-size: 2em;
margin: 0;
background: #3f3f3f;
background: var(--input_background);
border-radius: 0;
border-bottom-right-radius: 90%;
}
.button_toggle_navigation:active {
padding: 12px 14px 14px 12px;
}
.page_navigation { .page_navigation {
position: fixed; position: fixed;
backface-visibility: hidden; backface-visibility: hidden;
@@ -393,10 +374,6 @@ table:not(.form) {
min-width: 100%; min-width: 100%;
} }
tr {
border-bottom: 1px var(--separator) solid;
}
tr>td { tr>td {
padding: 0.2em 0.5em; padding: 0.2em 0.5em;
} }
@@ -459,8 +436,8 @@ input[type="color"] {
border-radius: 6px; border-radius: 6px;
margin: 2px; margin: 2px;
background: var(--input_background); background: var(--input_background);
gap: 3px; gap: 2px;
padding: 3px; padding: 2px;
overflow: hidden; overflow: hidden;
color: var(--input_text); color: var(--input_text);
cursor: pointer; cursor: pointer;
@@ -501,7 +478,7 @@ input[type="color"]:focus {
color: var(--input_text); color: var(--input_text);
text-decoration: none; text-decoration: none;
background: var(--input_hover_background); background: var(--input_hover_background);
box-shadow: 0px 0px 0px 1px var(--highlight_color); box-shadow: 0px 0px 0px 1px var(--highlight_color) !important;
} }
button:active, button:active,
@@ -510,13 +487,12 @@ input[type="submit"]:active,
input[type="button"]:active, input[type="button"]:active,
input[type="color"]:active { input[type="color"]:active {
box-shadow: inset 4px 4px 6px var(--shadow_color); box-shadow: inset 4px 4px 6px var(--shadow_color);
/* Exactly 3px offset compared to the inactive padding to give a depth effect */ /* Exactly 2px offset compared to the inactive padding to give a depth effect */
padding: 6px 0px 0px 6px; padding: 4px 0px 0px 4px;
} }
.button_highlight { .button_highlight {
background: var(--highlight_background) !important; box-shadow: 0px 0px 0px 1px var(--highlight_color) !important;
color: var(--highlight_text_color) !important;
} }
.button_red { .button_red {

View File

@@ -10,16 +10,17 @@
<link id="stylesheet_layout" rel="stylesheet" type="text/css" href="/res/style/layout.css?v{{cacheID}}"/> <link id="stylesheet_layout" rel="stylesheet" type="text/css" href="/res/style/layout.css?v{{cacheID}}"/>
<link id="stylesheet_theme" rel="stylesheet" type="text/css" href="/theme.css"/> <link id="stylesheet_theme" rel="stylesheet" type="text/css" href="/theme.css"/>
<link rel="icon" sizes="32x32" href="/res/img/pixeldrain_32.png" /> <link rel="icon" sizes="32x32" href="/res/img/nova_32.png" />
<link rel="icon" sizes="128x128" href="/res/img/pixeldrain_128.png" /> <link rel="icon" sizes="64x64" href="/res/img/nova_64.png" />
<link rel="icon" sizes="152x152" href="/res/img/pixeldrain_152.png" /> <link rel="icon" sizes="128x128" href="/res/img/nova_128.png" />
<link rel="icon" sizes="180x180" href="/res/img/pixeldrain_180.png" /> <link rel="icon" sizes="152x152" href="/res/img/nova_152.png" />
<link rel="icon" sizes="192x192" href="/res/img/pixeldrain_192.png" /> <link rel="icon" sizes="180x180" href="/res/img/nova_180.png" />
<link rel="icon" sizes="196x196" href="/res/img/pixeldrain_196.png" /> <link rel="icon" sizes="192x192" href="/res/img/nova_192.png" />
<link rel="icon" sizes="256x256" href="/res/img/pixeldrain_256.png" /> <link rel="icon" sizes="196x196" href="/res/img/nova_196.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/res/img/pixeldrain_152.png" /> <link rel="icon" sizes="256x256" href="/res/img/nova_256.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/res/img/pixeldrain_180.png" /> <link rel="apple-touch-icon" sizes="128x128" href="/res/img/nova_128.png" />
<link rel="shortcut icon" sizes="196x196" href="/res/img/pixeldrain_196.png" /> <link rel="apple-touch-icon" sizes="256x256" href="/res/img/nova_256.png" />
<link rel="shortcut icon" sizes="256x256" href="/res/img/nova_256.png" />
{{ template "opengraph" .OGData }} {{ template "opengraph" .OGData }}
<script> <script>

View File

@@ -162,7 +162,8 @@ onMount(() => {
<section> <section>
<h3>Bandwidth usage and file views</h3> <h3>Bandwidth usage and file views</h3>
</section> </section>
<div class="highlight_border" style="margin-bottom: 6px;">
<div>
<button onclick={() => loadGraph(1440, 1, true)}>Day 1m</button> <button onclick={() => loadGraph(1440, 1, true)}>Day 1m</button>
<button onclick={() => loadGraph(10080, 10, true)}>Week 10m</button> <button onclick={() => loadGraph(10080, 10, true)}>Week 10m</button>
<button onclick={() => loadGraph(43200, 60, true)}>Month 1h</button> <button onclick={() => loadGraph(43200, 60, true)}>Month 1h</button>
@@ -172,9 +173,11 @@ onMount(() => {
<button onclick={() => loadGraph(1051200, 1440, false)}>Two Years 1d</button> <button onclick={() => loadGraph(1051200, 1440, false)}>Two Years 1d</button>
<button onclick={() => loadGraph(2628000, 1440, false)}>Five Years 1d</button> <button onclick={() => loadGraph(2628000, 1440, false)}>Five Years 1d</button>
</div> </div>
<Chart bind:this={graphEgress} data_type="bytes" />
<Chart bind:this={graphDownloads} data_type="number" /> <Chart bind:this={graphEgress} data_type="bytes" ticks={false}/>
<div class="highlight_border"> <Chart bind:this={graphDownloads} data_type="number" ticks={false}/>
<div>
Total usage from {start_time} to {end_time}<br/> Total usage from {start_time} to {end_time}<br/>
{formatDataVolume(total_egress, 3)} egress, {formatDataVolume(total_egress, 3)} egress,
{formatThousands(total_downloads)} downloads {formatThousands(total_downloads)} downloads

View File

@@ -9,30 +9,32 @@ import { loading_finish, loading_start } from "lib/Loading";
const groups: { const groups: {
title: string, title: string,
expanded: boolean
graphs: { graphs: {
metric: string, metric: string,
agg_base?: string, agg_base?: string,
agg_divisor?: string, agg_divisor?: string,
data_type: string, data_type: string,
}[], }[],
}[] = [ }[] = $state([
{ {
title: "API", title: "API", expanded: false,
graphs: [ graphs: [
{metric: "api_request", data_type: "number"}, {metric: "api_request", data_type: "number"},
{metric: "api_request_duration", data_type: "duration"}, {metric: "api_request_duration", data_type: "duration"},
{metric: "api_request_duration_avg", agg_base: "api_request_duration", agg_divisor: "api_request", data_type: "duration"}, {metric: "api_request_duration_avg", agg_base: "api_request_duration", agg_divisor: "api_request", data_type: "duration"},
{metric: "api_error", data_type: "number"}, {metric: "api_error_400", data_type: "number"},
{metric: "api_error_500", data_type: "number"},
{metric: "api_panic", data_type: "number"}, {metric: "api_panic", data_type: "number"},
], ],
}, { }, {
title: "Task scheduler", title: "Task scheduler", expanded: false,
graphs: [ graphs: [
{metric: "scheduler_filesystem_remove_orphan", data_type: "number"}, {metric: "scheduler_filesystem_remove_orphan", data_type: "number"},
{metric: "scheduler_timeseries_delete", data_type: "number"}, {metric: "scheduler_timeseries_delete", data_type: "number"},
], ],
}, { }, {
title: "Database", title: "Database", expanded: false,
graphs: [ graphs: [
{metric: "database_query", data_type: "number"}, {metric: "database_query", data_type: "number"},
{metric: "database_query_duration", data_type: "duration"}, {metric: "database_query_duration", data_type: "duration"},
@@ -43,7 +45,7 @@ const groups: {
{metric: "database_query_error", data_type: "number"}, {metric: "database_query_error", data_type: "number"},
], ],
}, { }, {
title: "Pixelstore", title: "Pixelstore", expanded: false,
graphs: [ graphs: [
{metric: "pixelstore_write", data_type: "number"}, {metric: "pixelstore_write", data_type: "number"},
{metric: "pixelstore_write_size", data_type: "bytes"}, {metric: "pixelstore_write_size", data_type: "bytes"},
@@ -55,7 +57,7 @@ const groups: {
{metric: "pixelstore_peer_up", data_type: "number"}, {metric: "pixelstore_peer_up", data_type: "number"},
], ],
}, { }, {
title: "Pixelstore reads", title: "Pixelstore reads", expanded: false,
graphs: [ graphs: [
{metric: "pixelstore_cache_read", data_type: "number"}, {metric: "pixelstore_cache_read", data_type: "number"},
{metric: "pixelstore_cache_read_size", data_type: "bytes"}, {metric: "pixelstore_cache_read_size", data_type: "bytes"},
@@ -67,7 +69,7 @@ const groups: {
{metric: "pixelstore_read_retry_error", data_type: "number"}, {metric: "pixelstore_read_retry_error", data_type: "number"},
], ],
}, { }, {
title: "Pixelstore shards", title: "Pixelstore shards", expanded: false,
graphs: [ graphs: [
{metric: "pixelstore_shard_delete", data_type: "number"}, {metric: "pixelstore_shard_delete", data_type: "number"},
{metric: "pixelstore_shard_delete_size", data_type: "bytes"}, {metric: "pixelstore_shard_delete_size", data_type: "bytes"},
@@ -77,7 +79,7 @@ const groups: {
{metric: "pixelstore_shard_move_size", data_type: "bytes"}, {metric: "pixelstore_shard_move_size", data_type: "bytes"},
], ],
}, { }, {
title: "Pixelstore API", title: "Pixelstore API", expanded: false,
graphs: [ graphs: [
{metric: "pixelstore_api_error_400", data_type: "number"}, {metric: "pixelstore_api_error_400", data_type: "number"},
{metric: "pixelstore_api_error_500", data_type: "number"}, {metric: "pixelstore_api_error_500", data_type: "number"},
@@ -88,7 +90,7 @@ const groups: {
{metric: "pixelstore_api_status", data_type: "number"}, {metric: "pixelstore_api_status", data_type: "number"},
], ],
}, },
] ])
let dataWindow: number = $state(60) let dataWindow: number = $state(60)
let dataInterval: number = $state(1) let dataInterval: number = $state(1)
@@ -106,12 +108,17 @@ const load_metrics = async (window: number, interval: number) => {
let metrics_list: string[] = [] let metrics_list: string[] = []
for (const group of groups) { for (const group of groups) {
if (group.expanded === true) {
for (const graph of group.graphs) { for (const graph of group.graphs) {
if (graph.metric !== undefined) { if (graph.metric !== undefined) {
metrics_list.push(graph.metric) metrics_list.push(graph.metric)
} }
} }
} }
}
if (metrics_list.length === 0) {
return
}
loading_start() loading_start()
try { try {
@@ -130,6 +137,9 @@ const load_metrics = async (window: number, interval: number) => {
// If the dataset uses the duration type, we need to convert the values // If the dataset uses the duration type, we need to convert the values
// to milliseconds // to milliseconds
for (const group of groups) { for (const group of groups) {
if (group.expanded === false) {
continue
}
for (const graph of group.graphs) { for (const graph of group.graphs) {
if (graph.data_type === "duration" && metrics.metrics[graph.metric] !== undefined) { if (graph.data_type === "duration" && metrics.metrics[graph.metric] !== undefined) {
for (const host of Object.keys(metrics.metrics[graph.metric])) { for (const host of Object.keys(metrics.metrics[graph.metric])) {
@@ -198,7 +208,7 @@ onDestroy(() => {
</div> </div>
{#each groups as group (group.title)} {#each groups as group (group.title)}
<Expandable click_expand expanded> <Expandable click_expand bind:expanded={group.expanded} on_expand={(expanded) => load_metrics(dataWindow, dataInterval)}>
{#snippet header()} {#snippet header()}
<div class="title">{group.title}</div> <div class="title">{group.title}</div>
{/snippet} {/snippet}

View File

@@ -1,20 +1,16 @@
<script lang="ts"> <script lang="ts">
import { preventDefault } from 'svelte/legacy';
import { fs_encode_path } from "lib/FilesystemAPI.svelte"; import { fs_encode_path } from "lib/FilesystemAPI.svelte";
import type { FSNavigator } from "./FSNavigator"; import { path_link, type FSNavigator } from "./FSNavigator";
import { menu_is_open } from "wrap/MainMenu.svelte";
let { nav }: { let { nav }: {
nav: FSNavigator; nav: FSNavigator;
} = $props(); } = $props();
</script> </script>
<div class="breadcrumbs"> <div class="breadcrumbs" class:menu_closed={!$menu_is_open}>
{#each $nav.path as node, i (node.path)} {#each $nav.path as node, i (node.path)}
<a <a href={"/d"+fs_encode_path(node.path)} class="breadcrumb button flat" use:path_link={{nav: nav, node: node}}>
href={"/d"+fs_encode_path(node.path)}
class="breadcrumb button flat"
onclick={preventDefault(() => {nav.navigate(node.path, true)})}
>
{#if node.abuse_type !== undefined} {#if node.abuse_type !== undefined}
<i class="icon small">block</i> <i class="icon small">block</i>
{:else if node.is_shared()} {:else if node.is_shared()}
@@ -62,4 +58,7 @@ let { nav }: {
/* The base name uses all available space */ /* The base name uses all available space */
max-width: unset; max-width: unset;
} }
.menu_closed {
padding-left: 2em;
}
</style> </style>

View File

@@ -53,12 +53,19 @@ export class FSNavigator {
} }
} }
last_requested_path: string = ""
navigate = async (path: string, push_history: boolean) => { navigate = async (path: string, push_history: boolean) => {
if (path === this.last_requested_path) {
console.debug("FSNavigator: Requested path ", path, " is equal to current path. Debouncing")
return
}
this.last_requested_path = path
if (path[0] !== "/") { if (path[0] !== "/") {
path = "/" + path path = "/" + path
} }
console.debug("Navigating to path", path, push_history) console.debug("FSNavigator: Navigating to path", path, push_history)
try { try {
loading_start() loading_start()
@@ -89,6 +96,7 @@ export class FSNavigator {
} }
reload = async () => { reload = async () => {
this.last_requested_path = ""
await this.navigate(this.base.path, false) await this.navigate(this.base.path, false)
} }
@@ -97,7 +105,7 @@ export class FSNavigator {
// 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
if (this.history_enabled) { if (this.history_enabled) {
window.document.title = node.path[node.base_index].name + " / FNX" window.document.title = node.path[node.base_index].name + " / Nova"
const url = "/d" + fs_encode_path(node.path[node.base_index].path) + window.location.hash const url = "/d" + fs_encode_path(node.path[node.base_index].path) + window.location.hash
if (push_history) { if (push_history) {
window.history.pushState({}, window.document.title, url) window.history.pushState({}, window.document.title, url)
@@ -273,3 +281,25 @@ const sort_children = (children: FSNode[], field: string, asc: boolean) => {
} }
}) })
} }
export let global_navigator = new FSNavigator(true)
export const path_link = (node: HTMLAnchorElement, data: { nav: FSNavigator, node: FSNode }) => {
// Set url
node.href = "/d" + fs_encode_path(data.node.path)
// Override click handler
const click = (e: MouseEvent) => {
e.preventDefault()
data.nav.navigate(data.node.path, true)
}
node.addEventListener("click", click)
return {
destroy() {
node.removeEventListener("click", click)
}
}
}

View File

@@ -6,8 +6,7 @@ import Breadcrumbs from "./Breadcrumbs.svelte";
import DetailsWindow from "./DetailsWindow.svelte"; import DetailsWindow from "./DetailsWindow.svelte";
import FilePreview from "./viewers/FilePreview.svelte"; import FilePreview from "./viewers/FilePreview.svelte";
import FSUploadWidget from "./upload_widget/FSUploadWidget.svelte"; import FSUploadWidget from "./upload_widget/FSUploadWidget.svelte";
import { type FSPath } from "lib/FilesystemAPI.svelte"; import { global_navigator } from "./FSNavigator"
import { FSNavigator } from "./FSNavigator"
import { css_from_path } from "filesystem/edit_window/Branding"; import { css_from_path } from "filesystem/edit_window/Branding";
import AffiliatePrompt from "user_home/AffiliatePrompt.svelte"; import AffiliatePrompt from "user_home/AffiliatePrompt.svelte";
import { current_page_store } from "wrap/RouterStore"; import { current_page_store } from "wrap/RouterStore";
@@ -20,20 +19,16 @@ let edit_window: EditWindow = $state()
let edit_visible = $state(false) let edit_visible = $state(false)
let details_window: DetailsWindow = $state() let details_window: DetailsWindow = $state()
const nav = $state(new FSNavigator(true)) const nav = global_navigator
onMount(() => { onMount(() => {
if ((window as any).intial_node !== undefined) { // There is a global navigation handler which captures link clicks and loads
console.debug("Loading initial node") // the right svelte components. When a filesystem link is clicked this store
nav.open_node((window as any).initial_node as FSPath, false) // is updated. Catch it and use the filesystem navigator to navigate to the
} else { // right file
console.debug("No initial node, fetching path", window.location.pathname)
nav.navigate(decodeURI(window.location.pathname).replace(/^\/d/, ""), false)
}
const page_sub = current_page_store.subscribe(() => { const page_sub = current_page_store.subscribe(() => {
console.debug("Caught page transition to", window.location.pathname) console.debug("Caught page transition to", window.location.pathname, "calling navigator")
nav.navigate(decodeURI(window.location.pathname).replace(/^\/d/, ""), false) nav.navigate(decodeURIComponent(window.location.pathname).replace(/^\/d/, ""), false)
}) })
// Subscribe to navigation updates. This function returns a deconstructor // Subscribe to navigation updates. This function returns a deconstructor
@@ -43,13 +38,16 @@ onMount(() => {
return return
} }
// Custom CSS rules for the whole viewer // Custom CSS rules for the whole viewer. The MainMenu applies its
document.documentElement.style = css_from_path(nav.path) // styles to the <html> element. So we apply to the <body> element, our
// styles take precedence since they're lower level, and we can clean it
// up afterwards without overwriting global style
document.body.style = css_from_path(nav.path)
}) })
return () => { return () => {
page_sub() page_sub()
nav_sub() nav_sub()
document.documentElement.style = "" document.body.style = ""
} }
}) })

View File

@@ -113,9 +113,9 @@ const add_styles = (style: Style, properties: FSNodeProperties) => {
const add_contrast = (color: string, amt: number) => { const add_contrast = (color: string, amt: number) => {
let hsl = rgb2hsl(parse(color)) // Convert hex to hsl let hsl = rgb2hsl(parse(color)) // Convert hex to hsl
// If the lightness is less than 40 it is considered a dark colour. This // If the lightness is less than 30 it is considered a dark colour. This
// threshold is 40 instead of 50 because overall dark text is more legible // threshold is 30 instead of 50 because overall dark text is more legible
if (hsl[2] < 40) { if (hsl[2] < 30) {
hsl[2] = hsl[2] + amt // Dark color, add lightness hsl[2] = hsl[2] + amt // Dark color, add lightness
} else { } else {
hsl[2] = hsl[2] - amt // Light color, remove lightness hsl[2] = hsl[2] - amt // Light color, remove lightness

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fs_rename, fs_update, type FSNode, type NodeOptions } from "lib/FilesystemAPI.svelte"; import { fs_rename, fs_update, type FSNode, type FSPermissions, type NodeOptions } from "lib/FilesystemAPI.svelte";
import Modal from "util/Modal.svelte"; import Modal from "util/Modal.svelte";
import BrandingOptions from "./BrandingOptions.svelte"; import BrandingOptions from "./BrandingOptions.svelte";
import { branding_from_props } from "./Branding"; import { branding_from_props } from "./Branding";
@@ -22,6 +22,10 @@ let {
visible: boolean; visible: boolean;
} = $props(); } = $props();
const default_permissions: FSPermissions = {
owner: false, read: false, write: false, delete: false
}
// Open the edit window. Argument 1 is the file to edit, 2 is whether the file // Open the edit window. Argument 1 is the file to edit, 2 is whether the file
// should be opened after the user finishes editing and 3 is the default tab // should be opened after the user finishes editing and 3 is the default tab
// that should be open when the window shows // that should be open when the window shows
@@ -45,9 +49,9 @@ export const edit = (f: FSNode, oae = false, open_tab = "") => {
} }
options.custom_domain_name = file.custom_domain_name options.custom_domain_name = file.custom_domain_name
options.link_permissions = file.link_permissions options.link_permissions = file.link_permissions === undefined ? default_permissions : file.link_permissions
options.user_permissions = file.user_permissions options.user_permissions = file.user_permissions === undefined ? {} : file.user_permissions
options.password_permissions = file.password_permissions options.password_permissions = file.password_permissions === undefined ? {} : file.password_permissions
branding_enabled = options.branding_enabled === "true" branding_enabled = options.branding_enabled === "true"
if (branding_enabled) { if (branding_enabled) {

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import Button from "layout/Button.svelte"; import Button from "layout/Button.svelte";
import { fs_delete_all, type FSNode } from "lib/FilesystemAPI.svelte"; import { fs_delete_all, type FSNode } from "lib/FilesystemAPI.svelte";
import PathLink from "filesystem/util/PathLink.svelte";
import type { FSNavigator } from "filesystem/FSNavigator"; import type { FSNavigator } from "filesystem/FSNavigator";
import { loading_finish, loading_start } from "lib/Loading"; import { loading_finish, loading_start } from "lib/Loading";
@@ -48,10 +47,8 @@ const delete_file = async (e: MouseEvent) => {
<legend>File settings</legend> <legend>File settings</legend>
{#if is_root_dir} {#if is_root_dir}
<div class="highlight_yellow"> <div class="highlight_yellow">
Filesystem root cannot be renamed. If this shared directory Filesystem root cannot be renamed. If this shared directory is in <a
is in href="/d/me">your filesystem</a> you can rename it from there
<PathLink nav={nav} path="/me">your filesystem</PathLink>
you can rename it from there
</div> </div>
{/if} {/if}
<div class="form_grid"> <div class="form_grid">

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { preventDefault } from 'svelte/legacy';
import { onMount } from "svelte"; import { onMount } from "svelte";
import { fs_mkdir } from "lib/FilesystemAPI.svelte"; import { fs_mkdir } from "lib/FilesystemAPI.svelte";
import Button from "layout/Button.svelte"; import Button from "layout/Button.svelte";
@@ -11,7 +10,9 @@ let { nav }: { nav: FSNavigator } = $props();
let name_input: HTMLInputElement = $state(); let name_input: HTMLInputElement = $state();
let new_dir_name = $state("") let new_dir_name = $state("")
let error_msg = $state("") let error_msg = $state("")
let create_dir = async () => { let create_dir = async (e: SubmitEvent) => {
e.preventDefault()
let form = new FormData() let form = new FormData()
form.append("type", "dir") form.append("type", "dir")
@@ -43,7 +44,7 @@ onMount(() => {
</div> </div>
{/if} {/if}
<form id="create_dir_form" class="create_dir" onsubmit={preventDefault(create_dir)}> <form id="create_dir_form" class="create_dir" onsubmit={create_dir}>
<img src="/res/img/mime/folder.png" class="icon" alt="icon"/> <img src="/res/img/mime/folder.png" class="icon" alt="icon"/>
<input class="dirname" type="text" bind:this={name_input} bind:value={new_dir_name} /> <input class="dirname" type="text" bind:this={name_input} bind:value={new_dir_name} />
<Button form="create_dir_form" type="submit" icon="create_new_folder" label="Create"/> <Button form="create_dir_form" type="submit" icon="create_new_folder" label="Create"/>

View File

@@ -69,7 +69,7 @@ const file_event: FileActionHandler = (action: FileAction, index: number, orig:
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) { if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
select_node(index) select_node(index)
} else { } else {
file_menu.open(nav.children[index], orig.target) file_menu.open(nav.children[index], orig.target, orig)
} }
break break
case FileAction.Edit: case FileAction.Edit:
@@ -86,7 +86,7 @@ const file_event: FileActionHandler = (action: FileAction, index: number, orig:
select_node(index) select_node(index)
break break
case FileAction.Menu: case FileAction.Menu:
file_menu.open(nav.children[index], orig.target) file_menu.open(nav.children[index], orig.target, orig)
break break
} }
} }
@@ -387,15 +387,7 @@ run(() => {
{#if creating_dir} {#if creating_dir}
<CreateDirectory nav={nav} /> <CreateDirectory nav={nav} />
{/if} {/if}
{#if $nav.base.path === "/me"}
<div class="highlight_shaded" style="background-color: rgba(255, 255, 0, 0.05); border-radius: 0;">
The filesystem is experimental!
<a href="/filesystem">Please read the guide</a>
</div> </div>
{/if}
</div>
{#if $nav.base.abuse_type !== undefined} {#if $nav.base.abuse_type !== undefined}
<div class="highlight_red"> <div class="highlight_red">

View File

@@ -19,16 +19,23 @@ let {
let dialog: Dialog = $state() let dialog: Dialog = $state()
let node: FSNode = $state(null) let node: FSNode = $state(null)
export const open = async (n: FSNode, target: EventTarget) => { export const open = async (n: FSNode, target: EventTarget, event: Event) => {
node = n node = n
let el: HTMLElement = (target as Element).closest("button")
if (el === null) {
el = (target as Element).closest("a")
}
// Wait for the view to update, so the dialog gets the proper measurements // Wait for the view to update, so the dialog gets the proper measurements
await tick() await tick()
let el: HTMLElement = (target as Element).closest("button")
if (el !== null) {
dialog.open(el.getBoundingClientRect()) dialog.open(el.getBoundingClientRect())
return
}
if (event instanceof MouseEvent) {
dialog.open(event)
return
}
console.error("Cannot find suitable target for spawning dialog window")
} }
const delete_node = async () => { const delete_node = async () => {

View File

@@ -112,7 +112,6 @@ td {
color: var(--highlight_text_color); color: var(--highlight_text_color);
} }
td { td {
padding: 2px;
vertical-align: middle; vertical-align: middle;
} }
.node_icon { .node_icon {

View File

@@ -176,9 +176,13 @@ const leave_confirmation = (e: BeforeUnloadEvent) => {
{/if} {/if}
</div> </div>
<div class="body"> <div class="body">
{#each upload_queue as job} {#each upload_queue as job, i}
{#if job.status !== "finished"} {#if job.status !== "finished"}
<UploadProgress bind:this={job.component} job={job} finish={finish_upload}/> <UploadProgress
bind:this={upload_queue[i].component}
bind:job={upload_queue[i]}
finish={finish_upload}
/>
{/if} {/if}
{/each} {/each}
</div> </div>

View File

@@ -32,7 +32,7 @@ export const upload_file = (
return return
} }
console.log("Uploading file to ", fs_path_url(path)) console.debug("Uploading file to", fs_path_url(path))
let xhr = new XMLHttpRequest(); let xhr = new XMLHttpRequest();
xhr.open("PUT", fs_path_url(path) + "?make_parents=true", true); xhr.open("PUT", fs_path_url(path) + "?make_parents=true", true);

View File

@@ -1,18 +0,0 @@
<script lang="ts">
import { preventDefault } from 'svelte/legacy';
import type { FSNavigator } from "filesystem/FSNavigator";
let {
nav,
path = "",
children
}: {
nav: FSNavigator;
path?: string;
children?: import('svelte').Snippet;
} = $props();
</script>
<a href={"/d"+path} onclick={preventDefault(() => {nav.navigate(path, true)})}>
{@render children?.()}
</a>

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { preventDefault } from 'svelte/legacy';
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { fs_path_url, fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte" import { fs_path_url, fs_encode_path, fs_node_icon, FSNode } from "lib/FilesystemAPI.svelte"
import TextBlock from "layout/TextBlock.svelte" import TextBlock from "layout/TextBlock.svelte"
import type { FSNavigator } from 'filesystem/FSNavigator'; import type { FSNavigator } from 'filesystem/FSNavigator';
@@ -13,7 +12,7 @@ let { nav, children }: {
let player: HTMLAudioElement = $state() let player: HTMLAudioElement = $state()
let playing = $state(false) let playing = $state(false)
let media_session = false let media_session = false
let siblings = $state([]) let siblings: FSNode[] = $state([])
export const toggle_playback = () => playing ? player.pause() : player.play() export const toggle_playback = () => playing ? player.pause() : player.play()
export const toggle_mute = () => player.muted = !player.muted export const toggle_mute = () => player.muted = !player.muted
@@ -28,6 +27,7 @@ export const seek = (delta: number) => {
} }
} }
var background_div: HTMLDivElement
export const update = async () => { export const update = async () => {
if (media_session) { if (media_session) {
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
@@ -38,6 +38,15 @@ export const update = async () => {
} }
siblings = await nav.get_siblings() siblings = await nav.get_siblings()
for(const sib of siblings) {
const lower = sib.name.toLowerCase()
if (lower === "cover.jpg" || lower === "cover.png" || lower === "cover.webp") {
console.debug("Found album cover image", sib)
background_div.style.backgroundImage = `url("/api/filesystem/${sib.path}")`
break
}
}
} }
onMount(() => { onMount(() => {
@@ -54,6 +63,7 @@ onMount(() => {
{@render children?.()} {@render children?.()}
<div bind:this={background_div} class="background_div">
<TextBlock width="1000px"> <TextBlock width="1000px">
<audio <audio
bind:this={player} bind:this={player}
@@ -82,11 +92,7 @@ onMount(() => {
<h2>Tracklist</h2> <h2>Tracklist</h2>
{#each siblings as sibling (sibling.path)} {#each siblings as sibling (sibling.path)}
<a <a href={"/d"+fs_encode_path(sibling.path)} class="node">
href={"/d"+fs_encode_path(sibling.path)}
onclick={preventDefault(() => nav.navigate(sibling.path, true))}
class="node"
>
{#if sibling.path === $nav.base.path} {#if sibling.path === $nav.base.path}
<i class="play_arrow icon">play_arrow</i> <i class="play_arrow icon">play_arrow</i>
{:else} {:else}
@@ -97,8 +103,15 @@ onMount(() => {
</a> </a>
{/each} {/each}
</TextBlock> </TextBlock>
</div>
<style> <style>
.background_div {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
min-height: 100%;
}
.player { .player {
width: 100%; width: 100%;
} }

View File

@@ -22,7 +22,7 @@ let upload_widget
<div class="page_content"> <div class="page_content">
<section> <section>
<p> <p>
FNX.storage is a platform for cost-effective cloud storage and Nova.storage is a platform for cost-effective cloud storage and
content delivery. We will store and serve your files at an extremely content delivery. We will store and serve your files at an extremely
competitive rate. competitive rate.
</p> </p>
@@ -36,7 +36,7 @@ let upload_widget
<div>€ 1 / TB</div> <div>€ 1 / TB</div>
</div> </div>
</div> </div>
<h2>What FNX is good at</h2> <h2>What Nova is good at</h2>
<ul> <ul>
<li> <li>
Serving large files to millions of people worldwide Serving large files to millions of people worldwide
@@ -147,11 +147,11 @@ header > h1 {
.header_image_container { .header_image_container {
text-align: initial; text-align: initial;
margin: auto; margin: auto;
margin-bottom: 1.5em; /*Offset for menu button*/ margin-bottom: 50px;
height: 150px; height: 100px;
width: 500px; width: 500px;
max-width: 100%; max-width: 100%;
background-image: url("/res/img/header_orbitron.webp"); background-image: url("/res/img/header.webp");
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: contain; background-size: contain;
background-position: center; background-position: center;

View File

@@ -18,7 +18,7 @@ let egress = $state(10) // TB
let avg_file_size = $state(1000) // kB let avg_file_size = $state(1000) // kB
$effect(() => { $effect(() => {
// FNX has a minimum file size of 10kB. Calculate the number of files from // Nova has a minimum file size of 10kB. Calculate the number of files from
// storage and avg file size, then calculate storage usage based on that. // storage and avg file size, then calculate storage usage based on that.
fnx_storage = Math.max(storage * 4, ((storage*10)/avg_file_size)*4) fnx_storage = Math.max(storage * 4, ((storage*10)/avg_file_size)*4)
fnx_egress = egress * 1 fnx_egress = egress * 1
@@ -67,14 +67,14 @@ $effect(() => {
<div class="bars"> <div class="bars">
<div> <div>
<div> <div>
FNX.storage - <Euro amount={fnx_total*1e6}/> / month<br/> Nova.storage - <Euro amount={fnx_total*1e6}/> / month<br/>
<Euro amount={fnx_storage*1e6}/> storage, <Euro amount={fnx_storage*1e6}/> storage,
<Euro amount={fnx_egress*1e6}/> egress <Euro amount={fnx_egress*1e6}/> egress
<ProgressBar used={fnx_total} total={price_max}/> <ProgressBar used={fnx_total} total={price_max}/>
</div> </div>
{#if avg_file_size < 10} {#if avg_file_size < 10}
<div> <div>
FNX counts a minimum file size of 10 kB. Files smaller than that Nova counts a minimum file size of 10 kB. Files smaller than that
are rounded up to 10 kB. are rounded up to 10 kB.
</div> </div>
{/if} {/if}
@@ -105,16 +105,16 @@ $effect(() => {
</div> </div>
<p> <p>
Note that while FNX.storage might not seem to be the cheapest option in some Note that while Nova.storage might not seem to be the cheapest option in some
cases, most cloud providers have extra hidden costs for API calls and cases, most cloud providers have extra hidden costs for API calls and
region-specific prices. This makes it very hard to accurately compare the region-specific prices. This makes it very hard to accurately compare the
pricing of these platforms. FNX.storage includes no hidden costs, I only pricing of these platforms. Nova.storage includes no hidden costs, I only
charge for storage and egress. charge for storage and egress.
</p> </p>
<p> <p>
Large cloud providers like Amazon, Microsoft and Google are excluded from Large cloud providers like Amazon, Microsoft and Google are excluded from
this calculation because their pricing is too complex accurately compare this calculation because their pricing is too complex accurately compare
them. Just assume that FNX wil be cheaper. them. Just assume that Nova wil be cheaper.
</p> </p>
<style> <style>

View File

@@ -4,7 +4,7 @@ let { children }: {
} = $props(); } = $props();
let dialog: HTMLDialogElement = $state() let dialog: HTMLDialogElement = $state()
export const open = (button_rect: DOMRect) => { export const open = (origin: DOMRect|MouseEvent) => {
// Show the window so we can get the location // Show the window so we can get the location
dialog.showModal() dialog.showModal()
@@ -15,10 +15,18 @@ export const open = (button_rect: DOMRect) => {
const max_left = window.innerWidth - dialog_rect.width - edge_offset const max_left = window.innerWidth - dialog_rect.width - edge_offset
const max_top = window.innerHeight - dialog_rect.height - edge_offset const max_top = window.innerHeight - dialog_rect.height - edge_offset
let min_left: number, min_top: number
if (origin instanceof DOMRect) {
// Position the dialog in horizontally in the center of the button and // Position the dialog in horizontally in the center of the button and
// verticially below it // verticially below it
const min_left = Math.max((button_rect.left + (button_rect.width/2)) - (dialog_rect.width/2), edge_offset) min_left = Math.max((origin.left + (origin.width/2)) - (dialog_rect.width/2), edge_offset)
const min_top = Math.max(button_rect.bottom, edge_offset) min_top = Math.max(origin.bottom, edge_offset)
} else if (origin instanceof MouseEvent) {
// Place the dialog at the bottom right of the mouse pointer, like
// regular context menus
min_left = Math.max(origin.clientX, edge_offset)
min_top = Math.max(origin.clientY, edge_offset)
}
// Place the window // Place the window
dialog.style.left = Math.round(Math.min(min_left, max_left)) + "px" dialog.style.left = Math.round(Math.min(min_left, max_left)) + "px"
@@ -35,6 +43,7 @@ export const close = () => {
// the dialog itself then the click was on the dialog background // the dialog itself then the click was on the dialog background
const click = (e: MouseEvent) => { const click = (e: MouseEvent) => {
if (e.target === dialog) { if (e.target === dialog) {
e.preventDefault()
dialog.close() dialog.close()
} }
} }
@@ -42,7 +51,7 @@ const click = (e: MouseEvent) => {
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<dialog bind:this={dialog} onclick={click}> <dialog bind:this={dialog} onclick={click} oncontextmenu={click}>
{@render children?.()} {@render children?.()}
</dialog> </dialog>

View File

@@ -6,6 +6,7 @@ let {
group_first = false, group_first = false,
group_middle = false, group_middle = false,
group_last = false, group_last = false,
highlight = true,
action, action,
children, children,
}: { }: {
@@ -15,6 +16,7 @@ let {
group_first?: boolean; group_first?: boolean;
group_middle?: boolean; group_middle?: boolean;
group_last?: boolean; group_last?: boolean;
highlight?: boolean;
action?: (e: MouseEvent) => void; action?: (e: MouseEvent) => void;
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
} = $props(); } = $props();
@@ -31,7 +33,7 @@ const click = (e: MouseEvent) => {
onclick={click} onclick={click}
type="button" type="button"
class="button" class="button"
class:button_highlight={on} class:button_highlight={on && highlight}
class:group_first class:group_first
class:group_middle class:group_middle
class:group_last class:group_last

View File

@@ -59,6 +59,10 @@ export class FSNode {
(this.password_permissions !== undefined && Object.keys(this.password_permissions).length > 0) (this.password_permissions !== undefined && Object.keys(this.password_permissions).length > 0)
} }
is_hidden = (): boolean => {
return this.name.startsWith(".")
}
download = () => { download = () => {
const a = document.createElement("a") const a = document.createElement("a")

View File

@@ -1,8 +1,14 @@
import { current_page_store, type Tab } from "wrap/RouterStore" import { global_navigator } from "filesystem/FSNavigator"
import { current_page_store } from "wrap/RouterStore"
export const highlight_current_page = (node: HTMLAnchorElement) => { export const highlight_current_page = (node: HTMLAnchorElement) => {
const set_highlight = () => { const set_highlight = () => {
if (window.location.pathname === URL.parse(node.href).pathname) { const currentpath = window.location.pathname + "/"
const hrefpath = URL.parse(node.href).pathname + "/"
if (
currentpath === hrefpath ||
(hrefpath !== "" && hrefpath !== "/" && currentpath.startsWith(hrefpath))
) {
node.classList.add("button_highlight") node.classList.add("button_highlight")
} else { } else {
node.classList.remove("button_highlight") node.classList.remove("button_highlight")
@@ -13,10 +19,12 @@ export const highlight_current_page = (node: HTMLAnchorElement) => {
set_highlight() set_highlight()
// Set up a listener with the page router to catch navigation events // Set up a listener with the page router to catch navigation events
const unsub = current_page_store.subscribe((page: Tab) => { set_highlight() }) const unsub = current_page_store.subscribe(() => { set_highlight() })
const unsub2 = global_navigator.subscribe(() => { set_highlight() })
return { return {
destroy() { destroy() {
unsub() unsub()
unsub2()
} }
} }
} }

View File

@@ -88,7 +88,8 @@ const login = async (e?: SubmitEvent) => {
{ {
method: "POST", method: "POST",
body: fd, body: fd,
credentials: "omit", // Dont send existing session cookies // Allow server to set cookies
credentials: "include",
}, },
)) ))
@@ -99,9 +100,6 @@ const login = async (e?: SubmitEvent) => {
} }
} }
// Save the session cookie
document.cookie = "pd_auth_key="+resp.auth_key+"; Max-Age=31536000;"
dispatch("login", {key: resp.auth_key}) dispatch("login", {key: resp.auth_key})
if (typeof login_redirect === "string" && login_redirect.startsWith("/")) { if (typeof login_redirect === "string" && login_redirect.startsWith("/")) {

View File

@@ -2,6 +2,7 @@
import { preventDefault } from 'svelte/legacy'; import { preventDefault } from 'svelte/legacy';
import { loading_finish, loading_start } from "lib/Loading"; import { loading_finish, loading_start } from "lib/Loading";
import { formatDate } from "util/Formatting"; import { formatDate } from "util/Formatting";
import CopyButton from 'layout/CopyButton.svelte';
let loaded = $state(false) let loaded = $state(false)
let rows = $state([]) let rows = $state([])
@@ -130,12 +131,15 @@ const logout = async (key) => {
<tbody> <tbody>
{#each rows as row (row.auth_key)} {#each rows as row (row.auth_key)}
<tr style="border-bottom: none;"> <tr style="border-bottom: none;">
<td>{row.auth_key}</td> <td>
<CopyButton text={row.auth_key} small_icon>Copy</CopyButton>
{row.auth_key}
</td>
<td>{formatDate(row.creation_time, true, true, false)}</td> <td>{formatDate(row.creation_time, true, true, false)}</td>
<td>{formatDate(row.last_used_time, true, true, false)}</td> <td>{formatDate(row.last_used_time, true, true, false)}</td>
<td>{row.creation_ip_address}</td> <td>{row.creation_ip_address}</td>
<td> <td>
<button onclick={preventDefault(() => {logout(row.auth_key)})} class="button button_red round"> <button onclick={(e) => {e.preventDefault();logout(row.auth_key)}} class="button button_red round">
<i class="icon">delete</i> <i class="icon">delete</i>
</button> </button>
</td> </td>

View File

@@ -3,6 +3,7 @@ import { get_endpoint, get_user, type User } from "lib/PixeldrainAPI";
import { onMount } from "svelte"; import { onMount } from "svelte";
import HotlinkProgressBar from "user_home/HotlinkProgressBar.svelte"; import HotlinkProgressBar from "user_home/HotlinkProgressBar.svelte";
import StorageProgressBar from "user_home/StorageProgressBar.svelte"; import StorageProgressBar from "user_home/StorageProgressBar.svelte";
import { formatThousands } from "util/Formatting";
let transfer_cap = $state(0) let transfer_cap = $state(0)
let transfer_used = $state(0) let transfer_used = $state(0)
@@ -53,6 +54,9 @@ onMount(async () => {
<StorageProgressBar used={user.storage_space_used} total={storage_limit}/> <StorageProgressBar used={user.storage_space_used} total={storage_limit}/>
<br/> <br/>
File count: {formatThousands(user.filesystem_node_count)}
<br/><br/>
Egress used (30 days): Egress used (30 days):
(<a href="/user/sharing/bandwidth">set custom limit</a>) (<a href="/user/sharing/bandwidth">set custom limit</a>)
<HotlinkProgressBar used={transfer_used} total={transfer_cap}></HotlinkProgressBar> <HotlinkProgressBar used={transfer_used} total={transfer_cap}></HotlinkProgressBar>

View File

@@ -185,7 +185,7 @@ onMount(() => {
max-width: 100%; max-width: 100%;
background: var(--body_background); background: var(--body_background);
border-radius: 8px; border-radius: 8px;
padding: 2px; border: 1px solid var(--separator);
text-align: initial; text-align: initial;
} }
.card_component { .card_component {

View File

@@ -1,212 +0,0 @@
<script>
import { preventDefault } from 'svelte/legacy';
import UploadProgressBar from "home_page/UploadProgressBar.svelte"
import { tick } from "svelte"
import UploadStats from "home_page/UploadStats.svelte";
export const pick_files = () => {
file_input_field.click()
}
// === UPLOAD LOGIC ===
let file_input_field = $state()
const file_input_change = (event) => {
// Start uploading the files async
upload_files(event.target.files)
// This resets the file input field
file_input_field.nodeValue = ""
}
const paste = (e) => {
if (e.clipboardData.files[0]) {
e.preventDefault();
e.stopPropagation();
upload_files(e.clipboardData.files)
}
}
let active_uploads = 0
let upload_queue = $state([])
let status = $state("idle") // idle, uploading, finished
let upload_stats = $state()
export const upload_files = async (files) => {
if (files.length === 0) {
return
}
// Add files to the queue
for (let i = 0; i < files.length; i++) {
if (files[i].type === "" && files[i].size === 0) {
continue
}
upload_queue.push({
file: files[i],
name: files[i].name,
status: "queued",
component: null,
id: "",
total_size: files[i].size,
loaded_size: 0,
on_finished: finish_upload,
})
}
// Reassign array and wait for tick to complete. After the tick is completed
// each upload progress bar will have bound itself to its array item
upload_queue = upload_queue
await tick()
start_upload()
}
const start_upload = () => {
let finished_count = 0
for (let i = 0; i < upload_queue.length && active_uploads < 3; i++) {
if (upload_queue[i].status == "queued") {
active_uploads++
upload_queue[i].component.start()
} else if (
upload_queue[i].status == "finished" ||
upload_queue[i].status == "error"
) {
finished_count++
}
}
if (active_uploads === 0 && finished_count != 0) {
status = "finished"
upload_stats.finish()
} else {
status = "uploading"
upload_stats.start()
}
}
const finish_upload = (file) => {
active_uploads--
start_upload()
}
const leave_confirmation = e => {
if (status === "uploading") {
e.preventDefault()
e.returnValue = "If you close the page your files will stop uploading. Do you want to continue?"
return e.returnValue
} else {
return null
}
}
// === SHARING BUTTONS ===
let share_link = ""
let input_album_name = $state("")
let btn_create_list
const create_list = async (title, anonymous) => {
let files = upload_queue.reduce(
(acc, curr) => {
if (curr.status === "finished") {
acc.push({"id": curr.id})
}
return acc
},
[],
)
const resp = await fetch(
window.api_endpoint+"/list",
{
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify({
"title": title,
"anonymous": anonymous,
"files": files
})
}
)
if(!resp.ok) {
return Promise.reject("HTTP error: "+resp.status)
}
return await resp.json()
}
const share_mail = () => window.open("mailto:please@set.address?subject=File%20on%20pixeldrain&body=" + share_link)
const share_twitter = () => window.open("https://twitter.com/share?url=" + share_link)
const share_facebook = () => window.open('https://www.facebook.com/sharer.php?u=' + share_link)
const share_reddit = () => window.open('https://www.reddit.com/submit?url=' + share_link)
const share_tumblr = () => window.open('https://www.tumblr.com/share/link?url=' + share_link)
const create_album = () => {
if (!input_album_name) {
return
}
create_list(input_album_name, false).then(resp => {
window.location = '/l/' + resp.id
}).catch(err => {
alert("Failed to create list. Server says this:\n"+err)
})
}
const keydown = (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) {
return // prevent custom shortcuts from interfering with system shortcuts
}
if (document.activeElement.type && document.activeElement.type === "text") {
return // Prevent shortcuts from interfering with input fields
}
switch (e.key) {
case "u": file_input_field.click(); break
case "l": btn_create_list.click(); break
case "e": share_mail(); break
case "w": share_twitter(); break
case "f": share_facebook(); break
case "r": share_reddit(); break
case "m": share_tumblr(); break
}
}
</script>
<svelte:window onpaste={paste} onkeydown={keydown} onbeforeunload={leave_confirmation} />
<input bind:this={file_input_field} onchange={file_input_change} type="file" name="file" multiple="multiple" class="hide"/>
<UploadStats bind:this={upload_stats} upload_queue={upload_queue}/>
{#if upload_queue.length > 1}
<div class="album_widget">
Create an album<br/>
<form class="album_name_form" onsubmit={preventDefault(create_album)}>
<div>Name:</div>
<input bind:value={input_album_name} type="text" placeholder="My album"/>
<button type="submit" disabled={status !== "finished"}>
<i class="icon">create_new_folder</i> Create
</button>
</form>
</div>
{/if}
{#each upload_queue as file}
<UploadProgressBar bind:this={file.component} job={file}></UploadProgressBar>
{/each}
<style>
.album_widget {
display: block;
border-bottom: 1px solid var(--separator);
}
.album_name_form {
display: inline-flex;
flex-direction: row;
align-items: center;
}
.hide {
display: none;
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { formatDataVolume, formatNumber } from "./Formatting"; import { formatDataVolume, formatDuration, formatNumber } from "./Formatting";
import { color_by_name } from "./Util"; import { color_by_name } from "./Util";
import { import {
Chart, Chart,
@@ -33,12 +33,14 @@ let {
legend = true, legend = true,
tooltips = true, tooltips = true,
ticks = true, ticks = true,
animations = true,
height = "300px" height = "300px"
}: { }: {
data_type?: string; data_type?: string;
legend?: boolean; legend?: boolean;
tooltips?: boolean; tooltips?: boolean;
ticks?: boolean; ticks?: boolean;
animations?: boolean;
height?: string; height?: string;
} = $props(); } = $props();
@@ -85,6 +87,9 @@ onMount(() => {
}, },
tooltip: { tooltip: {
enabled: tooltips, enabled: tooltips,
itemSort: (a, b): number => {
return <number>b.raw - <number>a.raw
},
}, },
}, },
layout: { layout: {
@@ -102,6 +107,8 @@ onMount(() => {
callback: function (value: number, index: number, values: Tick[]) { callback: function (value: number, index: number, values: Tick[]) {
if (data_type == "bytes") { if (data_type == "bytes") {
return formatDataVolume(value, 3); return formatDataVolume(value, 3);
} else if (data_type === "duration") {
return formatDuration(value, 2);
} }
return formatNumber(value, 3); return formatNumber(value, 3);
}, },
@@ -129,6 +136,11 @@ onMount(() => {
} }
} }
); );
if (!animations) {
chart_object.options.animation = false
chart_object.options.transitions.active.animation.duration = 0
}
}) })
</script> </script>

View File

@@ -8,6 +8,9 @@ export const toggle = () => {
const header_click = () => { const header_click = () => {
if (click_expand) { if (click_expand) {
toggle() toggle()
if (on_expand !== null) {
on_expand(expanded)
}
} }
} }
@@ -22,6 +25,7 @@ const keypress = e => {
let { let {
expanded = $bindable(false), expanded = $bindable(false),
click_expand = false, click_expand = false,
on_expand = null,
highlight = false, highlight = false,
header, header,
children children
@@ -33,6 +37,10 @@ let {
// stopPropagation if you want to use other interactive elements in the // stopPropagation if you want to use other interactive elements in the
// title bar // title bar
click_expand?: boolean; click_expand?: boolean;
// Callback for when the user expands the view
on_expand?: (expanded: boolean) => void;
// Highlight the title bar if the user moves their mouse over it // Highlight the title bar if the user moves their mouse over it
highlight?: boolean; highlight?: boolean;
header?: import('svelte').Snippet; header?: import('svelte').Snippet;

View File

@@ -58,11 +58,24 @@ const hour = minute * 60
const day = hour * 24 const day = hour * 24
export const formatDuration = (ms: number, decimals: number) => { export const formatDuration = (ms: number, decimals: number) => {
let remainingDecimals = decimals
let res = "" let res = ""
if (ms >= day) { res += Math.floor(ms / day) + "d " } if (ms >= day && remainingDecimals > 0) {
if (ms >= hour) { res += Math.floor((ms % day) / hour) + "h " } res += Math.floor(ms / day) + "d "
if (ms >= minute) { res += Math.floor((ms % hour) / minute) + "m " } remainingDecimals--
return res + ((ms % minute) / second).toFixed(decimals) + "s" }
if (ms >= hour && remainingDecimals > 0) {
res += Math.floor((ms % day) / hour) + "h "
remainingDecimals--
}
if (ms >= minute && remainingDecimals > 0) {
res += Math.floor((ms % hour) / minute) + "m "
remainingDecimals--
}
if (remainingDecimals > 0) {
res += ((ms % minute) / second).toFixed(remainingDecimals) + "s"
}
return res
} }
export const formatDate = ( export const formatDate = (

View File

@@ -135,6 +135,7 @@ these padding divs to move it 25% up */
border-radius: 18px 18px 8px 8px; border-radius: 18px 18px 8px 8px;
overflow: hidden; overflow: hidden;
text-align: left; text-align: left;
border: 1px solid var(--separator);
} }
.header { .header {
flex-grow: 0; flex-grow: 0;

View File

@@ -49,7 +49,7 @@ const get_page = () => {
} }
title = current_subpage === null ? current_page.title : current_subpage.title title = current_subpage === null ? current_page.title : current_subpage.title
window.document.title = title+" / FNX" window.document.title = title+" / Nova"
} }
let current_page: Tab = $state(null) let current_page: Tab = $state(null)
@@ -106,6 +106,9 @@ onMount(() => {
{/if} {/if}
<style> <style>
header {
margin-top: 2em;
}
.submenu { .submenu {
border-bottom: 1px solid var(--separator); border-bottom: 1px solid var(--separator);
} }

View File

@@ -1,57 +1,140 @@
<script lang="ts"> <script lang="ts">
import { bookmark_del, bookmarks_store } from "lib/Bookmarks"; import { bookmarks_save, bookmarks_store } from "lib/Bookmarks";
import { fs_encode_path } from "lib/FilesystemAPI.svelte"; import { fs_encode_path } from "lib/FilesystemAPI.svelte";
import { highlight_current_page } from "lib/HighlightCurrentPage"; import { highlight_current_page } from "lib/HighlightCurrentPage";
import MenuEntry from "./MenuEntry.svelte";
import { flip } from "svelte/animate";
let { menu_collapsed }: { menu_collapsed: boolean } = $props(); let { menu_collapsed }: { menu_collapsed: boolean } = $props();
let editing = $state(false) let editing = $state(false)
const toggle_edit = () => { const toggle_edit = async () => {
if (editing) {
try {
await bookmarks_save($bookmarks_store)
} catch (err) {
alert("Failed to save bookmarks:\n"+err)
}
}
editing = !editing editing = !editing
} }
const delete_bookmark = (id: string) => {
console.debug("Deleting bookmark", id)
$bookmarks_store = $bookmarks_store.filter((bm) => bm.id !== id)
}
let hovering: number = $state(-1);
const enable_drag = (e: MouseEvent) => {
(e.target as HTMLElement).closest("div").setAttribute("draggable", "true");
}
const disable_drag = (e: MouseEvent) => {
(e.target as HTMLElement).closest("div").setAttribute("draggable", "false");
}
const drag = (e: DragEvent, index: number) => {
if (!editing) {
e.preventDefault()
return
}
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.dropEffect = 'move';
e.dataTransfer.setData('text/plain', index.toString());
}
const drop = (e: DragEvent, drop_idx: number) => {
hovering = -1
if (e.dataTransfer.files.length !== 0) {
// This is not a rearrangement, this is a file upload
return
}
e.dataTransfer.dropEffect = 'move';
let drag_idx = parseInt(e.dataTransfer.getData("text/plain"));
if ($bookmarks_store[drag_idx] === undefined) {
return
}
e.preventDefault()
// If the drag is up, we will place the item before the drop target. If the
// drag is down, we place it after the drop target
if (drag_idx < drop_idx) {
$bookmarks_store.splice(drop_idx + 1, 0, $bookmarks_store[drag_idx]);
$bookmarks_store.splice(drag_idx, 1);
} else if (drag_idx > drop_idx) {
$bookmarks_store.splice(drop_idx, 0, $bookmarks_store[drag_idx]);
$bookmarks_store.splice(drag_idx + 1, 1);
} else {
return; // Nothing changed
}
$bookmarks_store = $bookmarks_store
}
</script> </script>
{#if $bookmarks_store.length !== 0} {#if $bookmarks_store.length !== 0}
<div class="title"> <MenuEntry id="bookmarks" collapsed={menu_collapsed}>
<div class:hide={menu_collapsed}>Bookmarks</div> {#snippet title()}
<button onclick={() => toggle_edit()} class:button_highlight={editing}> <div class="title">Bookmarks</div>
<button onclick={toggle_edit} class:button_highlight={editing}>
{#if editing}
<i class="icon">save</i>
{:else}
<i class="icon">edit</i> <i class="icon">edit</i>
</button>
</div>
{/if} {/if}
</button>
{/snippet}
{#each $bookmarks_store as bookmark} {#snippet body()}
<div class="row"> {#each $bookmarks_store as bookmark, index (bookmark.id)}
<div class="row"
role="button"
tabindex="0"
ondragstart={e => drag(e, index)}
ondrop={e => drop(e, index)}
ondragover={e => {e.preventDefault(); e.stopPropagation()}}
ondragenter={() => hovering = index}
ondragend={() => {hovering = -1}}
class:highlight={hovering === index}
animate:flip={{duration: 300}}
>
{#if editing}
<button
class="button flat"
style="cursor: grab;"
onmousedown={enable_drag}
onmouseup={disable_drag}
>
<i class="icon">drag_indicator</i>
</button>
<input type="text" bind:value={bookmark.label}/>
<button onclick={() => delete_bookmark(bookmark.id)}>
<i class="icon">delete</i>
</button>
{:else}
<a class="button" href="/d{fs_encode_path(bookmark.path)}" use:highlight_current_page> <a class="button" href="/d{fs_encode_path(bookmark.path)}" use:highlight_current_page>
<i class="icon">{bookmark.icon}</i> <i class="icon">{bookmark.icon}</i>
<span class:hide={menu_collapsed}>{bookmark.label}</span> <span class:hide={menu_collapsed}>{bookmark.label}</span>
</a> </a>
{#if editing}
<button onclick={() => bookmark_del(bookmark.id)}>
<i class="icon">delete</i>
</button>
{/if} {/if}
</div> </div>
{/each} {/each}
{/snippet}
</MenuEntry>
{/if}
<style> <style>
.title { .title {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--separator);
}
.title > div {
flex: 1 1 auto; flex: 1 1 auto;
text-align: center; text-align: center;
} }
.title > button {
flex: 0 0 auto;
}
.row { .row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
} }
.row>a { .row>a {
flex: 1 1 auto; flex: 1 1 auto;
@@ -66,4 +149,8 @@ const toggle_edit = () => {
.hide { .hide {
display: none; display: none;
} }
.highlight {
box-shadow: 0 0 0px 1px var(--highlight_color);
text-decoration: none;
}
</style> </style>

View File

@@ -1,3 +1,6 @@
<script lang="ts" module>
export let menu_is_open = writable(true)
</script>
<script lang="ts"> <script lang="ts">
import { highlight_current_page } from "lib/HighlightCurrentPage"; import { highlight_current_page } from "lib/HighlightCurrentPage";
import { user } from "lib/UserStore"; import { user } from "lib/UserStore";
@@ -11,17 +14,16 @@ import { css_from_path } from "filesystem/edit_window/Branding";
import { loading_run, loading_store } from "lib/Loading"; import { loading_run, loading_store } from "lib/Loading";
import Spinner from "util/Spinner.svelte"; import Spinner from "util/Spinner.svelte";
import { get_user } from "lib/PixeldrainAPI"; import { get_user } from "lib/PixeldrainAPI";
import Tree from "./Tree.svelte";
import MenuEntry from "./MenuEntry.svelte";
import { writable } from "svelte/store";
let menu_collapsed = false onMount(() => {
if (document.documentElement.clientWidth < 1000) {
const toggle_menu = (e: MouseEvent) => { menu_close()
menu_collapsed = !menu_collapsed
} }
onMount(async () => { loading_run(async () => {
menu_collapsed = document.documentElement.clientWidth < 1000
await loading_run(async () => {
const user = await get_user() const user = await get_user()
if (user.username === undefined || user.username === "") { if (user.username === undefined || user.username === "") {
return return
@@ -30,31 +32,117 @@ onMount(async () => {
document.documentElement.style = css_from_path(root.path) document.documentElement.style = css_from_path(root.path)
}) })
}) })
// Dead zone before the swipe action gets detected
const swipe_initial_offset = 50
const min_screen_size_fullscreen_menu = 500
let menu: HTMLDivElement
let nav: HTMLElement
let dragging: boolean = false
let start_x: number
let render_offset: number
let initial_offset: number
const touchstart = (e: TouchEvent) => {
start_x = e.touches[0].clientX
const rect = menu.getBoundingClientRect()
if (start_x < Math.max(swipe_initial_offset, (rect.width+rect.left))) {
dragging = true
}
render_offset = rect.left
initial_offset = rect.left
}
const touchmove = (e: TouchEvent) => {
if (!dragging) {
return
}
set_offset(initial_offset+(e.touches[0].clientX - start_x))
}
const touchend = (e: TouchEvent) => {
if (!dragging) {
return
}
dragging = false
const menu_width = menu.getBoundingClientRect().width
if (render_offset > -(menu_width/2)) {
menu_open()
} else {
menu_close()
}
}
const toggle_menu = (e: MouseEvent) => {
if (menu.style.transform === "") {
menu_close()
} else {
menu_open()
}
}
const menu_open = () => {
set_offset(0)
menu_is_open.set(true)
if (document.documentElement.clientWidth < min_screen_size_fullscreen_menu) {
menu.style.position = "fixed"
menu.style.width = "100%"
nav.style.width = "100%"
nav.style.maxWidth = "100%"
} else {
menu.style.position = "relative"
menu.style.width = ""
nav.style.width = ""
nav.style.maxWidth = ""
}
}
const menu_close = () => {
set_offset(-menu.getBoundingClientRect().width)
menu_is_open.set(false)
menu.style.position = "fixed"
if (document.documentElement.clientWidth >= min_screen_size_fullscreen_menu) {
menu.style.width = ""
nav.style.width = ""
nav.style.maxWidth = ""
}
}
const set_offset = (off: number) => {
render_offset = off
if (off > -swipe_initial_offset) {
// Clear the transformation if the offset is zero
menu.style.transform = ""
} else {
menu.style.transform = "translateX(" + off + "px)"
}
}
</script> </script>
<div class="nav_container"> <svelte:window ontouchstart={touchstart} ontouchmove={touchmove} ontouchend={touchend}/>
<div class="scroll_container">
<nav class="nav"> <button class="button_toggle_navigation" onclick={toggle_menu}>
<button class="button" onclick={toggle_menu}>
<i class="icon">menu</i> <i class="icon">menu</i>
<span class:hide={menu_collapsed}>Collapse menu</span>
</button> </button>
<div class="nav_container" bind:this={menu}>
<div class="scroll_container">
<nav class="nav" bind:this={nav}>
<a class="button" href="/" use:highlight_current_page> <a class="button" href="/" use:highlight_current_page>
<i class="icon">home</i> <i class="icon">home</i>
<span class:hide={menu_collapsed}>Home</span> <span>Home</span>
</a> </a>
{#if $user.username !== undefined && $user.username !== ""} {#if $user.username !== undefined && $user.username !== ""}
<div class="separator" class:hide={menu_collapsed}></div> <MenuEntry id="subscription_info" collapsed={false}>
{#snippet title()}
<div class="username">{$user.username}</div>
{/snippet}
<div class="username" class:hide={menu_collapsed}> {#snippet body()}
{$user.username} <div class="stats_table">
</div>
<div class="separator"></div>
<div class="stats_table" class:hide={menu_collapsed}>
<div>Subscription</div> <div>Subscription</div>
<div>{$user.subscription.name}</div> <div>{$user.subscription.name}</div>
@@ -68,31 +156,31 @@ onMount(async () => {
<div>Transfer used</div> <div>Transfer used</div>
<div>{formatDataVolume($user.monthly_transfer_used, 3)}</div> <div>{formatDataVolume($user.monthly_transfer_used, 3)}</div>
</div> </div>
{/snippet}
<div class="separator" class:hide={menu_collapsed}></div> </MenuEntry>
<a class="button" href="/d/me" use:highlight_current_page> <a class="button" href="/d/me" use:highlight_current_page>
<i class="icon">folder</i> <i class="icon">folder</i>
<span class:hide={menu_collapsed}>Filesystem</span> <span>Filesystem</span>
</a> </a>
<a class="button" href="/user" use:highlight_current_page> <a class="button" href="/user" use:highlight_current_page>
<i class="icon">dashboard</i> <i class="icon">person</i>
<span class:hide={menu_collapsed}>Dashboard</span> <span>Account</span>
</a> </a>
{#if $user.is_admin} {#if $user.is_admin}
<a class="button" href="/admin" use:highlight_current_page> <a class="button" href="/admin" use:highlight_current_page>
<i class="icon">admin_panel_settings</i> <i class="icon">admin_panel_settings</i>
<span class:hide={menu_collapsed}>Admin Panel</span> <span>Admin Panel</span>
</a> </a>
{/if} {/if}
{:else} {:else}
<a class="button" href="/login" use:highlight_current_page> <a class="button" href="/login" use:highlight_current_page>
<i class="icon">login</i> <i class="icon">login</i>
<span class:hide={menu_collapsed}>Login</span> <span>Login</span>
</a> </a>
<a class="button" href="/register" use:highlight_current_page> <a class="button" href="/register" use:highlight_current_page>
<i class="icon">how_to_reg</i> <i class="icon">how_to_reg</i>
<span class:hide={menu_collapsed}>Register</span> <span>Register</span>
</a> </a>
{/if} {/if}
@@ -100,16 +188,15 @@ onMount(async () => {
<a class="button" href="/speedtest" use:highlight_current_page> <a class="button" href="/speedtest" use:highlight_current_page>
<i class="icon">speed</i> <i class="icon">speed</i>
<span class:hide={menu_collapsed}>Speedtest</span> <span>Speedtest</span>
</a> </a>
<a class="button" href="/appearance" use:highlight_current_page> <a class="button" href="/appearance" use:highlight_current_page>
<i class="icon">palette</i> <i class="icon">palette</i>
<span class:hide={menu_collapsed}>Themes</span> <span>Themes</span>
</a> </a>
<div class="separator"></div> <Bookmarks menu_collapsed={false}/>
<Tree menu_collapsed={false}/>
<Bookmarks menu_collapsed={menu_collapsed}/>
</nav> </nav>
</div> </div>
</div> </div>
@@ -138,23 +225,43 @@ onMount(async () => {
background-repeat: var(--background_image_repeat, repeat); background-repeat: var(--background_image_repeat, repeat);
background-attachment: fixed; background-attachment: fixed;
} }
.button_toggle_navigation {
position: fixed;
backface-visibility: hidden;
z-index: 10;
top: 0;
left: 0;
background: none;
box-shadow: none;
margin: 0;
padding: 4px;
border-radius: 0;
border-bottom-right-radius: 0px;
border-bottom-right-radius: 2px;
backdrop-filter: blur(6px);
}
.nav_container { .nav_container {
flex: 0 0 auto; flex: 0 0 auto;
border-right: 1px solid var(--separator); border-right: 1px solid var(--separator);
background: var(--shaded_background); background: var(--shaded_background);
backdrop-filter: blur(4px); backdrop-filter: blur(6px);
z-index: 9;
} }
.scroll_container { .scroll_container {
position: sticky; position: sticky;
top: 0; top: 0;
max-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
max-height: 100vh;
height: 100vh;
} }
.nav { .nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 15em;
min-width: 10em;
max-width: 15em; max-width: 15em;
padding-top: 2em;
} }
.nav > .button { .nav > .button {
background: none; background: none;
@@ -165,17 +272,17 @@ onMount(async () => {
overflow-x: hidden; overflow-x: hidden;
max-width: 100%; max-width: 100%;
} }
.username {
flex: 1 1 auto;
text-align: center;
margin: 3px;
}
.separator { .separator {
height: 1px; height: 1px;
margin: 2px 0;
width: 100%; width: 100%;
background-color: var(--separator); background-color: var(--separator);
} }
.username {
text-align: center;
margin: 3px;
}
.stats_table { .stats_table {
display: grid; display: grid;
grid-template-columns: auto auto; grid-template-columns: auto auto;
@@ -183,10 +290,6 @@ onMount(async () => {
margin: 5px; margin: 5px;
} }
.hide {
display: none;
}
.spinner { .spinner {
position: fixed; position: fixed;
top: 10px; top: 10px;

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import ToggleButton from "layout/ToggleButton.svelte";
import { onMount } from "svelte";
type Expandable = {[key: string]: boolean}
let {
id,
collapsed = false,
title,
body,
}: {
id: string
collapsed: boolean
title: import('svelte').Snippet
body: import('svelte').Snippet
} = $props();
let expanded: boolean = $state(true)
const get_status = (): Expandable => {
let exp = localStorage.getItem("menu_expanded")
if (exp === null) {
exp = "{}"
}
return JSON.parse(exp) as Expandable
}
onMount(() => {
let exp = get_status()
if (exp[id] !== undefined) {
expanded = exp[id]
}
})
const toggle = (e: MouseEvent) => {
let exp = get_status()
exp[id] = expanded
localStorage.setItem("menu_expanded", JSON.stringify(exp))
}
</script>
<div class="title">
<ToggleButton bind:on={expanded} action={toggle} icon_on="arrow_drop_down" icon_off="arrow_drop_up" highlight={false}/>
{#if !collapsed}
{@render title()}
{/if}
</div>
{#if expanded}
{@render body()}
{/if}
<style>
.title {
display: flex;
flex-direction: row;
align-items: center;
border-top: 1px solid var(--separator);
}
</style>

View File

@@ -37,7 +37,6 @@ let pages: Tab[] = [
title: "Filesystem", title: "Filesystem",
component: Filesystem, component: Filesystem,
footer: false, footer: false,
login: true,
}, { }, {
path: "/admin", path: "/admin",
prefix: "/admin/", prefix: "/admin/",
@@ -64,9 +63,13 @@ onMount(async () => {
let current_page: Tab = $state(null) let current_page: Tab = $state(null)
const load_page = (pathname: string, history: boolean): boolean => { const load_page = (pathname: string, history: boolean): boolean => {
console.debug("Navigating to page", pathname, "log history:", history) console.debug(
"Page router: Navigating to page", pathname,
"current path", window.location.pathname,
"log history:", history,
)
const path_decoded = decodeURI(pathname) const path_decoded = decodeURIComponent(pathname)
let page_by_path: Tab = null let page_by_path: Tab = null
let page_by_prefix: Tab = null let page_by_prefix: Tab = null
for (const page of pages) { for (const page of pages) {
@@ -98,7 +101,7 @@ const load_page = (pathname: string, history: boolean): boolean => {
return load_page("/login", true) return load_page("/login", true)
} }
window.document.title = current_page.title+" / FNX" window.document.title = current_page.title+" / Nova"
if(history) { if(history) {
window.history.pushState({}, window.document.title, pathname) window.history.pushState({}, window.document.title, pathname)

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { global_navigator } 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";
import MenuEntry from "./MenuEntry.svelte";
let { menu_collapsed }: { menu_collapsed: boolean } = $props();
let siblings: FSNode[] = $state([])
onMount(() => {
return global_navigator.subscribe(async () => {
siblings = await global_navigator.get_siblings()
})
})
</script>
<MenuEntry id="tree_parents" collapsed={menu_collapsed}>
{#snippet title()}
<div class="title">Parent directories</div>
<button title="Navigate up" onclick={() => global_navigator.navigate_up()}>
<i class="icon">north</i>
</button>
{/snippet}
{#snippet body()}
{#each $global_navigator.path.slice(0, $global_navigator.path.length-1) as node (node.id)}
{#if node.type === "dir"}
<div class="row">
<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 class:hide={menu_collapsed}>{node.name}</span>
</a>
</div>
{/if}
{/each}
{/snippet}
</MenuEntry>
<MenuEntry id="tree_siblings" collapsed={menu_collapsed}>
{#snippet title()}
<div class="title">Siblings</div>
<button title="Open previous sibling" onclick={() => global_navigator.open_sibling(-1)}>
<i class="icon">west</i>
</button>
<button title="Open next sibling" onclick={() => global_navigator.open_sibling(1)}>
<i class="icon">east</i>
</button>
{/snippet}
{#snippet body()}
{#each siblings as node (node.id)}
{#if !node.is_hidden()}
<div class="row">
<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 class:hide={menu_collapsed}>{node.name}</span>
</a>
</div>
{/if}
{/each}
{/snippet}
</MenuEntry>
<style>
.title {
flex: 1 1 auto;
text-align: center;
margin: 3px;
}
.row {
display: flex;
flex-direction: row;
}
.row>a {
flex: 1 1 auto;
}
.row>a>span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.button {
background: none;
box-shadow: none;
}
.thumbnail {
height: 1.5em;
width: 1.5em;
border-radius: 2px;
}
.hide {
display: none;
}
</style>

View File

@@ -21,6 +21,7 @@ export default defineConfig({
outDir: builddir, outDir: builddir,
emptyOutDir: true, emptyOutDir: true,
minify: production, minify: production,
sourcemap: !production,
lib: { lib: {
entry: "src/wrap.js", entry: "src/wrap.js",
name: "fnx_web", name: "fnx_web",