Compare commits

...

3 Commits

Author SHA1 Message Date
f3522f370f Overhaul trash page 2026-08-03 23:45:37 +02:00
912fe06ff6 Add documentation link in menu 2026-08-03 23:31:54 +02:00
0acc6a91bb Update sharing settings 2026-08-03 23:31:43 +02:00
8 changed files with 284 additions and 431 deletions

View File

@@ -1,28 +1,31 @@
<script lang="ts"> <script lang="ts">
import { formatDataVolume, formatDate } from "util/Formatting"; import { formatDataVolume, formatDate } from "util/Formatting";
import { fs_check_response, fs_encode_path, fs_get_trash, fs_node_icon, fs_path_url, type TrashEntry } from "lib/FilesystemAPI.svelte"; import { fs_check_response, fs_encode_path, fs_get_trash, fs_node_icon, fs_path_url, type TrashEntry } from "lib/FilesystemAPI.svelte";
import { loading_finish, loading_start } from "lib/Loading"; import { loading_finish, loading_run, loading_start, loading_store } from "lib/Loading";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { breadcrumbs_store } from "wrap/BreadcrumbStore";
import { FSNavigator } from "filesystem/FSNavigator";
let { root_path = "/me" }: { root_path?: string } = $props(); let {
nav,
root_path = "/me",
}: {
nav: FSNavigator
root_path?: string
} = $props();
let entries: TrashEntry[] = $state([]); let entries: TrashEntry[] = $state([]);
let error: string = $state(""); let error: string = $state("");
let loaded = $state(false);
const load = async () => { const load = async () => {
loaded = false; loading_run(async () => {
entries = []; try {
try { entries = await fs_get_trash(root_path)
loading_start(); error = "";
entries = await fs_get_trash(root_path) } catch (err: any) {
error = ""; error = err?.message ?? JSON.stringify(err);
} catch (err: any) { }
error = err?.message ?? JSON.stringify(err); })
} finally {
loaded = true;
loading_finish();
}
}; };
const delete_entry = async (entry: TrashEntry) => { const delete_entry = async (entry: TrashEntry) => {
@@ -76,122 +79,112 @@ const empty_trash = async () => {
} }
}; };
onMount(load); onMount(() => {
breadcrumbs_store.set(breadcrumbs)
load()
});
</script> </script>
<div class="trash_container">
<div class="header"> {#snippet breadcrumbs()}
<h2 class="title"><i class="icon">delete</i> Trash</h2> <i class="icon">delete</i>
<div class="actions"> <div>
<button onclick={load}> Trash bin
<i class="icon">refresh</i> {#if $loading_store === 0 && entries.length === 0}
<span>Refresh</span> (empty)
</button> {:else if entries.length !== 0}
{#if entries.length > 0} ({formatDataVolume(entries.reduce(
<button class="danger" onclick={empty_trash}> (acc, entry) => acc + entry.node.file_size, 0
<i class="icon">delete_forever</i> ), 3)})
<span>Empty trash</span>
</button>
{/if} {/if}
</div> </div>
</div> <div class="separator"></div>
<button onclick={() => window.history.back()} title="Back">
<i class="icon">arrow_back</i>
</button>
<button onclick={() => nav.navigate_up()} disabled={$nav.path.length <= 1} title="Up">
<i class="icon">north</i>
</button>
<button onclick={() => nav.reload()} title="Refresh directory listing">
<i class="icon">refresh</i>
</button>
{#if entries.length !== 0}
<button class="button button_red" onclick={empty_trash}>
<i class="icon">delete_forever</i>
<span>Delete all</span>
</button>
{/if}
{/snippet}
{#if error} {#if error}
<p class="error">{error}</p> <p class="error">{error}</p>
{:else if loaded && entries.length === 0} {:else if entries.length > 0}
<p class="empty">The trash is empty.</p> <table class="trash_table">
{:else if entries.length > 0} <thead>
<table class="trash_table"> <tr>
<thead> <td></td>
<tr> <td>Name</td>
<td></td> <td class="hide_small">Original location</td>
<td>Name</td> <td class="hide_small">Deleted at</td>
<td class="hide_small">Original location</td> <td class="hide_small">Size</td>
<td class="hide_small">Deleted</td> <td></td>
<td class="hide_small">Size</td> </tr>
<td></td> </thead>
<tbody>
{#each entries as entry (entry.node.id)}
<tr class="entry">
<td class="icon_cell">
<img
src={fs_node_icon(entry.node, 32, 32)}
class="node_icon"
alt="icon"
/>
</td>
<td class="name_cell">
<a class="entry_link" href={"/d" + fs_encode_path(entry.node.path)}>
{entry.node.name}
</a>
</td>
<td class="hide_small">
{entry.original_path ? entry.original_path.slice(0, -entry.node.name.length) : "—"}
</td>
<td class="hide_small">
{entry.deletion_date ? formatDate(entry.deletion_date) : "—"}
</td>
<td class="hide_small">
{#if entry.node.type === "file"}
{formatDataVolume(entry.node.file_size, 3)}
{/if}
</td>
<td>
<div class="row_actions">
<button title="Restore to original location" onclick={() => restore_entry(entry)}>
<i class="icon">restore_from_trash</i>
</button>
<button class="button button_red" title="Delete permanently" onclick={() => delete_entry(entry)}>
<i class="icon">delete_forever</i>
</button>
</div>
</td>
</tr> </tr>
</thead> {/each}
<tbody> </tbody>
{#each entries as entry (entry.node.id)} </table>
<tr class="entry"> {/if}
<td class="icon_cell">
<img
src={fs_node_icon(entry.node, 32, 32)}
class="node_icon"
alt="icon"
/>
</td>
<td class="name_cell">
<a class="entry_link" href={"/d" + fs_encode_path(entry.node.path)}>
{entry.node.name}
</a>
</td>
<td class="hide_small original_path">
{entry.original_path || "—"}
</td>
<td class="hide_small date_cell">
{entry.deletion_date ? formatDate(entry.deletion_date) : "—"}
</td>
<td class="hide_small size_cell">
{#if entry.node.type === "file"}
{formatDataVolume(entry.node.file_size, 3)}
{/if}
</td>
<td class="button_cell">
<div class="row_actions">
<button title="Restore to original location" onclick={() => restore_entry(entry)}>
<i class="icon">restore_from_trash</i>
</button>
<button class="danger" title="Delete permanently" onclick={() => delete_entry(entry)}>
<i class="icon">delete_forever</i>
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<style> <style>
.trash_container { .separator {
max-width: 1200px; flex: 1 1 auto;
margin: auto;
padding: 1em;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5em;
margin-bottom: 1em;
}
.title {
display: flex;
align-items: center;
gap: 0.3em;
margin: 0;
font-size: 1.2em;
border: none;
}
.actions {
display: flex;
gap: 0.5em;
} }
.error { .error {
color: var(--danger_color, #c0392b); color: var(--danger_color, #c0392b);
} }
.empty {
color: var(--body_text_color);
opacity: 0.6;
}
.trash_table { .trash_table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: var(--body_background); background: var(--body_background);
border-radius: 8px;
} }
.trash_table td { .trash_table td {
padding: 0; padding: 0;
@@ -205,7 +198,6 @@ onMount(load);
} }
.icon_cell { .icon_cell {
width: 2.5em; width: 2.5em;
padding: 0.3em 0.3em 0.3em 0.5em;
} }
.node_icon { .node_icon {
display: block; display: block;
@@ -214,7 +206,6 @@ onMount(load);
object-fit: contain; object-fit: contain;
} }
.name_cell { .name_cell {
padding: 0.3em 0.5em;
word-break: break-all; word-break: break-all;
} }
.entry_link { .entry_link {
@@ -224,34 +215,10 @@ onMount(load);
.entry_link:hover { .entry_link:hover {
text-decoration: underline; text-decoration: underline;
} }
.original_path {
padding: 0.3em 0.5em;
color: var(--body_text_color);
opacity: 0.7;
font-size: 0.85em;
word-break: break-all;
}
.date_cell {
padding: 0.3em 0.5em;
white-space: nowrap;
font-size: 0.85em;
}
.size_cell {
padding: 0.3em 0.5em;
white-space: nowrap;
font-size: 0.85em;
text-align: right;
}
.button_cell {
padding: 0.3em 0.5em;
}
.row_actions { .row_actions {
display: flex; display: flex;
gap: 0.25em; gap: 0.25em;
} }
.danger {
color: var(--danger_color, #c0392b);
}
@media (max-width: 600px) { @media (max-width: 600px) {
.hide_small { .hide_small {
display: none; display: none;

View File

@@ -90,7 +90,7 @@ export const seek = (delta: number) => {
<Spinner/> <Spinner/>
</div> </div>
{:else if viewer_type === "dir" && $nav.base.name === ".Trash"} {:else if viewer_type === "dir" && $nav.base.name === ".Trash"}
<TrashBin root_path={$nav.path[$nav.path.length - 2].path}/> <TrashBin nav={nav} root_path={$nav.path[$nav.path.length - 2].path}/>
{:else if viewer_type === "dir"} {:else if viewer_type === "dir"}
<FileManager nav={nav} upload_widget={upload_widget} edit_window={edit_window} search_bar={search_bar}> <FileManager nav={nav} upload_widget={upload_widget} edit_window={edit_window} search_bar={search_bar}>
<CustomBanner path={$nav.path}/> <CustomBanner path={$nav.path}/>

View File

@@ -11,6 +11,7 @@ export type GenericResponse = {
} }
export type User = { export type User = {
id: string,
username: string, username: string,
email: string, email: string,
otp_enabled: boolean, otp_enabled: boolean,
@@ -20,12 +21,10 @@ export type User = {
filesystem_node_count: number, filesystem_node_count: number,
is_admin: boolean, is_admin: boolean,
balance_micro_eur: number, balance_micro_eur: number,
hotlinking_enabled: boolean,
monthly_transfer_cap: number, monthly_transfer_cap: number,
monthly_transfer_used: number, monthly_transfer_used: number,
file_viewer_branding: Map<string, string>, file_viewer_branding: Map<string, string>,
file_embed_domains: string, embed_domains: string,
skip_file_viewer: boolean,
affiliate_user_name: string, affiliate_user_name: string,
checkout_country: string, checkout_country: string,
checkout_name: string, checkout_name: string,
@@ -158,10 +157,12 @@ export const put_user = async (data: Object) => {
)) ))
// Update the user store // Update the user store
for (let key of Object.keys(data)) { user.update((user: User) => {
cached_user[key] = data[key] for (let key of Object.keys(data)) {
} user[key] = data[key]
user.set(cached_user) }
return user
})
} }
export const logout_user = async (redirect_path: string) => { export const logout_user = async (redirect_path: string) => {

View File

@@ -1,148 +0,0 @@
<script>
import { preventDefault } from 'svelte/legacy';
import { onMount } from "svelte";
import Pro from "icons/Pro.svelte";
import { formatDataVolume } from "util/Formatting";
import ProgressBar from "util/ProgressBar.svelte";
import SuccessMessage from "util/SuccessMessage.svelte";
import { loading_finish, loading_start } from "lib/Loading";
import { put_user } from 'lib/NovaAPI';
import { user } from 'lib/UserStore';
let success_message = $state()
let hotlinking = $state($user === null ? false : $user.hotlinking_enabled)
let transfer_cap = $state($user === null ? 0 : $user.monthly_transfer_cap / 1e12)
let skip_viewer = $state($user === null ? false : $user.skip_file_viewer)
const update = async () => {
loading_start()
try {
await put_user({
hotlinking_enabled: hotlinking,
transfer_cap: transfer_cap*1e12,
skip_file_viewer: skip_viewer,
})
success_message.set(true, "Sharing settings updated")
} catch (err) {
success_message.set(false, "Failed to update subscription: "+err)
} finally {
loading_finish()
}
}
let toggle_hotlinking = () => {
hotlinking = !hotlinking
update()
}
let transfer_used = $state(0)
let load_transfer_used = () => {
let today = new Date()
let start = new Date()
start.setDate(start.getDate() - 30)
fetch(
window.api_endpoint + "/user/time_series/egress" +
"?start=" + start.toISOString() +
"&end=" + today.toISOString() +
"&interval=60"
).then(resp => {
if (!resp.ok) { return Promise.reject("Error: " + resp.status); }
return resp.json();
}).then(resp => {
transfer_used = resp.amounts.reduce((acc, cur) => { return acc + cur }, 0)
}).catch(e => {
console.error("Error requesting time series: " + e);
})
}
let toggle_skip_viewer = () => {
skip_viewer = !skip_viewer
update()
}
onMount(() => {
load_transfer_used()
})
</script>
<section>
<h2><Pro/>Hotlinking (bandwidth sharing)</h2>
<SuccessMessage bind:this={success_message}></SuccessMessage>
<button onclick={toggle_hotlinking}>
{#if hotlinking}
<i class="icon green">check</i> ON (click to turn off)
{:else}
<i class="icon red">close</i> OFF (click to turn on)
{/if}
</button>
<p>
When hotlinking is enabled all the bandwidth that your files use will be
subtracted from your data cap. Advertisements will be disabled on the
download pages for your files and download speed will be unlimited. The
rate limiting captcha for files is also disabled when hotlinking is on.
You can directly embed your file's download link anywhere, you don't
need to use the file viewer page.
</p>
<h2><Pro/>Bill shock limit</h2>
<p>
Billshock limit in terabytes per month (1 TB = 1000 GB). Set to 0 to
disable.
</p>
<form onsubmit={preventDefault(update)} class="billshock_container">
<input type="number" bind:value={transfer_cap} step="0.1" min="0" style="width: 5em;"/>
<div style="margin: 0.5em;">TB</div>
<button type="submit">
<i class="icon">save</i> Save
</button>
</form>
<p>
Bandwidth used in the last 30 days: {formatDataVolume(transfer_used, 3)},
new limit: {formatDataVolume(transfer_cap*1e12, 3)}
</p>
<ProgressBar used={transfer_used} total={transfer_cap*1e12}></ProgressBar>
<p>
The billshock limit limits how much bandwidth your account can use in a
30 day window. When this limit is reached hotlinking will be disabled
and you will no longer be charged for bandwidth usage. This is mostly
useful for the Prepaid subscription, but it works for Patreon plans too.
Set to 0 to disable the limit.
</p>
<h2><Pro/>Skip download page</h2>
<button onclick={toggle_skip_viewer}>
{#if skip_viewer}
<i class="icon green">check</i> ON (click to turn off)
{:else}
<i class="icon red">close</i> OFF (click to turn on)
{/if}
</button>
<p>
This setting only applies to your account, others will still see the
download page when visiting your files. When this is enabled you will be
redirected to the file download API when clicking a Nova link.
This means that images, videos and PDF files will be opened directly in
your browser, and other files are immediately downloaded to your device.
This only works for single file links, not albums. You will need a Pro
subscription to use this feature.
</p>
</section>
<style>
.green {
color: var(--highlight_color);
}
.red {
color: var(--danger_color);
}
.billshock_container {
display: flex;
flex-direction: row;
align-items: center;
}
</style>

View File

@@ -1,118 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import SuccessMessage from "util/SuccessMessage.svelte";
import { get_endpoint, get_user, type User } from "lib/NovaAPI";
import { loading_run } from "lib/Loading";
let success_message: SuccessMessage
// Embedding settings
let embed_domains: string[] = []
const save_embed = async () => {
await loading_run(async () => {
const domain_list = embed_domains.join(" ")
const form = new FormData()
form.append("embed_domains", domain_list)
try {
const resp = await fetch(
get_endpoint()+"/user",
{ method: "PUT", body: form }
);
if(resp.status >= 400) {
let json = await resp.json()
console.debug(json)
throw json.message
}
success_message.set(true, "Changes saved")
} catch(err) {
success_message.set(false, err)
}
})
}
let new_domain: string = ""
const add = () => {
console.debug("Adding domain", new_domain)
embed_domains.push(new_domain)
embed_domains = embed_domains
new_domain = ""
save_embed()
}
const remove = (index: number) => {
embed_domains.splice(index, 1)
embed_domains = embed_domains
save_embed()
}
let user: User
onMount(async () => {
user = await get_user()
if (user.file_embed_domains !== "") {
embed_domains = user.file_embed_domains.split(" ")
}
})
</script>
<section>
<h2>Embedding controls</h2>
<SuccessMessage bind:this={success_message}></SuccessMessage>
{#if user !== undefined && !user.hotlinking_enabled}
<div class="highlight_yellow">
To use embedding restrictions hotlinking needs to be enabled.
Enable hotlinking on the
<a href="/user/sharing/bandwidth">sharing settings page</a>.
</div>
{/if}
<p>
Here you can control which websites are allowed to embed your files in
their web pages. If a website that is not on this list tries to embed
one of your files the request will be blocked. This applies to both
hotlink and iframe embeds. If the list is empty, all domains are allowed
to embed your files.
</p>
<div class="highlight_border">
<form on:submit|preventDefault={() => add()}>
<div class="form_row">
<span>Add domain name</span>
<input class="grow" bind:value={new_domain} type="text"/>
<button class="shrink" type="submit"><i class="icon">add</i> Add</button>
</div>
</form>
<hr/>
{#if embed_domains.length === 0}
<span>
No domains specified. All websites are allowed to embed your files
</span>
{/if}
{#each embed_domains as domain, index}
<div class="form_row">
<button class="shrink" type="button" on:click={() => remove(index)}>
<i class="icon">delete</i>
</button>
<div>{domain}</div>
</div>
{/each}
</div>
</section>
<style>
.highlight_border {
text-align: initial;
}
.form_row {
display: inline-flex;
flex-direction: row;
width: 100%;
align-items: center;
gap: 0.5em;
}
.grow {
flex: 1 1 auto;
}
.shrink {
flex: 0 0 auto;
}
</style>

View File

@@ -7,12 +7,11 @@ import ConnectApp from "./ConnectApp.svelte";
import ActivityLog from "./ActivityLog.svelte"; import ActivityLog from "./ActivityLog.svelte";
import DepositCredit from "./DepositCredit.svelte"; import DepositCredit from "./DepositCredit.svelte";
import TabMenu, { type Tab } from "util/TabMenu.svelte"; import TabMenu, { type Tab } from "util/TabMenu.svelte";
import BandwidthSharing from "./BandwidthSharing.svelte";
import EmbeddingControls from "./EmbeddingControls.svelte";
import Dashboard from "./dashboard/Dashboard.svelte"; import Dashboard from "./dashboard/Dashboard.svelte";
import AffiliatePrompt from "./AffiliatePrompt.svelte"; import AffiliatePrompt from "./AffiliatePrompt.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { get_user, type User } from "lib/NovaAPI"; import { get_user, type User } from "lib/NovaAPI";
import SharingSettings from "./SharingSettings.svelte";
let pages: Tab[] = [ let pages: Tab[] = [
{ {
@@ -50,21 +49,9 @@ let pages: Tab[] = [
], ],
}, { }, {
path: "/user/sharing", path: "/user/sharing",
title: "Sharing", title: "Sharing settings",
icon: "share", icon: "share",
subpages: [ component: SharingSettings,
{
path: "/user/sharing/bandwidth",
title: "Sharing settings",
icon: "share",
component: BandwidthSharing,
}, {
path: "/user/sharing/embedding",
title: "Embedding Controls",
icon: "code",
component: EmbeddingControls,
},
],
}, { }, {
path: "/user/connect_app", path: "/user/connect_app",
title: "Apps", title: "Apps",

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { onMount } from "svelte";
import { formatDataVolume } from "util/Formatting";
import ProgressBar from "util/ProgressBar.svelte";
import { loading_run } from "lib/Loading";
import { get_endpoint, put_user } from 'lib/NovaAPI';
import SuccessMessage from 'util/SuccessMessage.svelte';
import { user } from "lib/UserStore";
let success_message: SuccessMessage = $state()
let transfer_cap = $state($user === null ? 0 : $user.monthly_transfer_cap / 1e12)
const update_user = async (e: SubmitEvent) => {
if (e !== null) {
e.preventDefault()
}
loading_run(async () => {
try {
await put_user({
transfer_cap: transfer_cap*1e12,
embed_domains: embed_domains.join(" "),
})
success_message.set(true, "Sharing settings updated")
} catch (err) {
success_message.set(false, "Failed to update user: "+err)
}
})
}
let transfer_used = $state(0)
let load_transfer_used = () => {
let today = new Date()
let start = new Date()
start.setDate(start.getDate() - 30)
fetch(
get_endpoint() + "/user/time_series/egress" +
"?start=" + start.toISOString() +
"&end=" + today.toISOString() +
"&interval=60"
).then(resp => {
if (!resp.ok) { return Promise.reject("Error: " + resp.status); }
return resp.json();
}).then(resp => {
transfer_used = resp.amounts.reduce((acc, cur) => { return acc + cur }, 0)
}).catch(e => {
console.error("Error requesting time series: " + e);
})
}
// Embedding settings
let embed_domains: string[] = $state($user === null ? [] : $user.embed_domains.split(" "))
let new_domain: string = $state("")
const add_domain = (e: SubmitEvent) => {
e.preventDefault()
console.debug("Adding domain", new_domain)
embed_domains.push(new_domain)
embed_domains = embed_domains
new_domain = ""
update_user(e)
}
const del_domain = (index: number) => {
embed_domains.splice(index, 1)
embed_domains = embed_domains
update_user(null)
}
onMount(async () => {
load_transfer_used()
})
</script>
<section>
<SuccessMessage bind:this={success_message}/>
<h2>Bill shock limit</h2>
<p>
Billshock limit in terabytes per month (1 TB = 1000 GB). Set to 0 to
disable.
</p>
<form onsubmit={update_user} class="billshock_container">
<input type="number" bind:value={transfer_cap} step="0.1" min="0" style="width: 5em;"/>
<div style="margin: 0.5em;">TB</div>
<button type="submit">
<i class="icon">save</i> Save
</button>
</form>
<p>
Bandwidth used in the last 30 days: {formatDataVolume(transfer_used, 3)},
new limit: {formatDataVolume(transfer_cap*1e12, 3)}
</p>
<ProgressBar used={transfer_used} total={transfer_cap*1e12}></ProgressBar>
<p>
The billshock limit limits how much bandwidth your account can use in a
30 day window. When this limit is reached hotlinking will be disabled
and you will no longer be charged for bandwidth usage. This is mostly
useful for the Prepaid subscription, but it works for Patreon plans too.
Set to 0 to disable the limit.
</p>
<h2>Embedding controls</h2>
<p>
Here you can control which websites are allowed to embed your files in
their web pages. If a website that is not on this list tries to embed
one of your files the request will be blocked. This applies to both
hotlink and iframe embeds. If the list is empty, all domains are allowed
to embed your files.
</p>
<div class="highlight_border">
<form onsubmit={add_domain}>
<div class="form_row">
<span>Add domain name</span>
<input class="grow" bind:value={new_domain} type="text"/>
<button class="shrink" type="submit"><i class="icon">add</i> Add</button>
</div>
</form>
<hr/>
{#if embed_domains.length === 0}
<span>
No domains specified. All websites are allowed to embed your files
</span>
{/if}
{#each embed_domains as domain, index}
<div class="form_row">
<button class="shrink" type="button" onclick={() => del_domain(index)}>
<i class="icon">delete</i>
</button>
<div>{domain}</div>
</div>
{/each}
</div>
</section>
<style>
.billshock_container {
display: flex;
flex-direction: row;
align-items: center;
}
.highlight_border {
text-align: initial;
}
.form_row {
display: inline-flex;
flex-direction: row;
width: 100%;
align-items: center;
gap: 0.5em;
}
.grow {
flex: 1 1 auto;
}
.shrink {
flex: 0 0 auto;
}
</style>

View File

@@ -236,6 +236,10 @@ const set_offset = (off: number) => {
<i class="icon">palette</i> <i class="icon">palette</i>
<span>Themes</span> <span>Themes</span>
</a> </a>
<a class="button" href="https://docs.nova.storage" use:highlight_current_page>
<i class="icon">text_snippet</i>
<span>Documentation</span>
</a>
{/snippet} {/snippet}
</MenuEntry> </MenuEntry>