Compare commits

...

11 Commits

49 changed files with 502 additions and 456 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

@@ -393,10 +393,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 +455,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 +497,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 +506,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={graphEgress} data_type="bytes" />
<Chart bind:this={graphDownloads} data_type="number" /> <Chart bind:this={graphDownloads} data_type="number" />
<div class="highlight_border">
<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,5 +1,4 @@
<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 type { FSNavigator } from "./FSNavigator";
@@ -10,11 +9,7 @@ let { nav }: {
<div class="breadcrumbs"> <div class="breadcrumbs">
{#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">
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()}

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 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()
@@ -97,7 +104,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 +280,5 @@ const sort_children = (children: FSNode[], field: string, asc: boolean) => {
} }
}) })
} }
export let global_navigator = new FSNavigator(true)

View File

@@ -7,7 +7,7 @@ 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 { type FSPath } from "lib/FilesystemAPI.svelte";
import { FSNavigator } from "./FSNavigator" import { global_navigator } 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,7 +20,7 @@ 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) { if ((window as any).intial_node !== undefined) {
@@ -28,12 +28,16 @@ onMount(() => {
nav.open_node((window as any).initial_node as FSPath, false) nav.open_node((window as any).initial_node as FSPath, false)
} else { } else {
console.debug("No initial node, fetching path", window.location.pathname) console.debug("No initial node, fetching path", window.location.pathname)
nav.navigate(decodeURI(window.location.pathname).replace(/^\/d/, ""), false) nav.navigate(decodeURIComponent(window.location.pathname).replace(/^\/d/, ""), false)
} }
// There is a global natigation handler which captures link clicks and loads
// the right svelte components. When a filesystem link is clicked this store
// is updated. Catch it and use the filesystem navigator to navigate to the
// right file
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 +47,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

@@ -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)

View File

@@ -2,6 +2,7 @@
import { bookmark_del, bookmarks_store } from "lib/Bookmarks"; import { bookmark_del, 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";
let { menu_collapsed }: { menu_collapsed: boolean } = $props(); let { menu_collapsed }: { menu_collapsed: boolean } = $props();
@@ -13,14 +14,15 @@ const toggle_edit = () => {
</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()}
<div class="title">Bookmarks</div>
<button onclick={() => toggle_edit()} class:button_highlight={editing}> <button onclick={() => toggle_edit()} class:button_highlight={editing}>
<i class="icon">edit</i> <i class="icon">edit</i>
</button> </button>
</div> {/snippet}
{/if}
{#snippet body()}
{#each $bookmarks_store as bookmark} {#each $bookmarks_store as bookmark}
<div class="row"> <div class="row">
<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>
@@ -34,21 +36,15 @@ const toggle_edit = () => {
{/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;

View File

@@ -11,17 +11,19 @@ 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";
let menu_collapsed = false let menu_collapsed: boolean = $state(false)
const toggle_menu = (e: MouseEvent) => { const toggle_menu = (e: MouseEvent) => {
menu_collapsed = !menu_collapsed menu_collapsed = !menu_collapsed
} }
onMount(async () => { onMount(() => {
menu_collapsed = document.documentElement.clientWidth < 1000 menu_collapsed = document.documentElement.clientWidth < 1000
await loading_run(async () => { 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
@@ -34,7 +36,7 @@ onMount(async () => {
<div class="nav_container"> <div class="nav_container">
<div class="scroll_container"> <div class="scroll_container">
<nav class="nav"> <nav class="nav" class:collapse={menu_collapsed}>
<button class="button" 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> <span class:hide={menu_collapsed}>Collapse menu</span>
@@ -48,12 +50,12 @@ onMount(async () => {
{#if $user.username !== undefined && $user.username !== ""} {#if $user.username !== undefined && $user.username !== ""}
<div class="separator" class:hide={menu_collapsed}></div> <div class="separator" class:hide={menu_collapsed}></div>
<div class="username" class:hide={menu_collapsed}> <MenuEntry id="subscription_info" collapsed={menu_collapsed}>
{$user.username} {#snippet title()}
</div> <div class="username">{$user.username}</div>
{/snippet}
<div class="separator"></div>
{#snippet body()}
<div class="stats_table" class:hide={menu_collapsed}> <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,8 +70,8 @@ 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>
@@ -110,6 +112,7 @@ onMount(async () => {
<div class="separator"></div> <div class="separator"></div>
<Bookmarks menu_collapsed={menu_collapsed}/> <Bookmarks menu_collapsed={menu_collapsed}/>
<Tree menu_collapsed={menu_collapsed}/>
</nav> </nav>
</div> </div>
</div> </div>
@@ -154,8 +157,14 @@ onMount(async () => {
.nav { .nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 15em;
min-width: 10em;
max-width: 15em; max-width: 15em;
} }
.nav.collapse {
width: unset;
min-width: unset;
}
.nav > .button { .nav > .button {
background: none; background: none;
box-shadow: none; box-shadow: none;
@@ -165,17 +174,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;

View File

@@ -0,0 +1,73 @@
<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()}
<div class="separator"></div>
{/if}
<style>
.title {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--separator);
}
.separator {
height: 1px;
width: 100%;
background-color: 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",