Compare commits

...

11 Commits

Author SHA1 Message Date
99ab34c76d Fix error popup when canceling a sharing action
Fix navigator not reloading when saving an edited file
Remove dead links
Remove references to Patreon
2026-08-05 22:18:57 +02:00
4c57a363b2 Fix:
* Breadcrumbs too wide in deeply nested directories
* Sharedialog copies link and calls navigator.share at the same time
* Link copy feedback is not clear
* Show embed code when public read
* Hide hidden files from audio playlist
2026-08-05 21:54:49 +02:00
00b40ab900 Add filesystem indexing metrics 2026-08-05 21:52:28 +02:00
b02e88eb1b Add button to remove peers 2026-08-04 14:38:36 +02:00
196845d739 Remove unused Go templates 2026-08-04 13:37:59 +02:00
a12424ef66 Add configurable columns to directory view 2026-08-04 13:37:44 +02:00
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
5b25f21f72 Update home page a bit 2026-07-30 23:15:00 +02:00
1536b8bd1b Block sharing if subscription does not support it 2026-07-30 23:13:55 +02:00
38 changed files with 693 additions and 899 deletions

View File

@@ -1,50 +0,0 @@
{{define "email_confirm"}}<!DOCTYPE html>
<html lang="en">
<head>
{{template "meta_tags" "E-mail verification"}}
</head>
<body>
{{template "page_top" .}}
{{if eq .Other "success"}}
<header>
<h1>Success!</h1>
</header>
<div id="page_content" class="page_content">
<section>
<p>
Your account's e-mail address has been updated.
</p>
</section>
</div>
{{else if eq .Other "not_found"}}
<header>
<h1>E-mail change failed</h1>
</header>
<div id="page_content" class="page_content">
<section>
<p>
This e-mail change request does not exist or has expired.
Please try again if you still want to change your e-mail
address.
</p>
</section>
</div>
{{else}}
<header>
<h1>Error</h1>
</header>
<div id="page_content" class="page_content">
<section>
<p>
Something went wrong while processing this request. Please
try again later.
</p>
</section>
</div>
{{end}}
{{template "page_bottom" .}}
</body>
</html>
{{end}}

View File

@@ -1,90 +0,0 @@
{{define "menu"}}
<button id="button_toggle_navigation" class="button_toggle_navigation icon" onclick="toggleMenu();">
menu
</button>
<nav id="page_navigation" class="page_navigation">
<a href="/home#">Home</a>
{{if eq .User.Subscription.ID ""}}
<a href="/home#pro">Get Premium</a>
{{end}}
<hr />
{{if .Authenticated}}
<a href="/user">{{.User.Username}}</a>
{{if .User.Subscription.FilesystemAccess}}
<a href="/d/me">Filesystem</a>
{{end}}
{{if .User.IsAdmin}}
<a href="/admin">Admin Panel</a>
{{end}}
<a href="/logout">Log out</a>
{{else}}
<a href="/login">Login</a>
<a href="/register">Register</a>
{{end}}
<hr />
<a href="/about">Questions & Answers</a>
<a href="/apps">Apps</a>
<a href="/appearance">Theme</a>
<a href="/speedtest">Speedtest</a>
<a href="/api">API</a>
<a href="/filesystem">Filesystem Guide</a>
<a href="/acknowledgements">Acknowledgements</a>
<a href="/abuse">DMCA and abuse</a>
<a href="https://stats.uptimerobot.com/p9v2ktzyjm" target="_blank">Server Status</a>
</nav>
<script>
function toggleMenu() {
var nav = document.getElementById("page_navigation");
var body = document.getElementById("page_body");
if (nav.offsetLeft === 0) {
// Menu is visible, hide it
nav.style.left = -nav.offsetWidth + "px";
body.style.marginLeft = "0";
} else {
// Menu is hidden, show it
nav.style.left = "0";
body.style.marginLeft = nav.offsetWidth + "px";
}
}
function resetMenu() {
document.getElementById("page_navigation").style.left = "";
document.getElementById("page_body").style.marginLeft = "";
}
</script>
{{end}}
{{define "footer"}}
<footer>
<div class="footer_content">
<div style="display: inline-block; margin: 0 8px;">
Nova is a product by <a href="//fornaxian.tech" target="_blank">Fornaxian Technologies</a>
</div>
<br/>
<div style="display: inline-block; margin: 0 8px;">
<a href="https://github.com/Fornaxian" target="_blank">{{template `github.svg` .}} GitHub</a> |
<a href="https://mastodon.social/web/@fornax" target="_blank">{{template `mastodon.svg` .}} Mastodon</a>
</div>
<br/>
<div style="display: inline-block; margin: 0 8px;">
{{$speed := .PixelAPI.GetMiscClusterSpeed}}
Server speed: {{ formatDataBits $speed.ServerTX }}ps |
Cache cluster: {{ formatDataBits $speed.CacheTX }}ps |
Storage cluster: {{ formatDataBits $speed.StorageTX }}ps
</div>
<br/>
<span class="small_footer_text" style="font-size: .75em; line-height: .75em;">
page rendered by {{.Hostname}}
</span>
</div>
</footer>
{{end}}
{{define "page_top"}}
{{template "menu" .}}
<div id="page_body" class="page_body">
{{end}}
{{define "page_bottom"}}
{{template "footer" .}}
</div>
{{end}}

View File

@@ -1,40 +0,0 @@
{{define "logout"}}<!DOCTYPE html>
<html lang="en">
<head>
{{template "meta_tags" "Logging out..."}}
</head>
<body>
{{template "page_top" .}}
<header>
<h1>Logging out of your Nova account</h1>
</header>
<div id="page_content" class="page_content">
<br/>
<form method="POST" action="/logout">
<button role="submit" class="button_highlight">
<i class="icon">logout</i>
Log out of Nova on this computer
</button>
</form>
<br/>
<section>
<h2>Why do I need to confirm my logout?</h2>
<p>
We need you to confirm your action so we can be sure that you
really requested a logout. If we didn't do this, anyone (or any
website) would be able to send you to this page and you would
automatically get logged out of Nova, which would be very
annoying.
</p>
<p>
To prevent this from happening we're verifying that you actually
want to log out by making you click this button. Because this
logout button triggers a different request type than normal
page visit we can confirm that you really want to log out.
</p>
</section>
</div>
{{template "page_bottom" .}}
</body>
</html>
{{end}}

View File

@@ -26,7 +26,26 @@ const groups: {
{metric: "api_error_400", data_type: "number"}, {metric: "api_error_400", data_type: "number"},
{metric: "api_error_500", data_type: "number"}, {metric: "api_error_500", data_type: "number"},
{metric: "api_panic", data_type: "number"}, {metric: "api_panic", data_type: "number"},
{metric: "api_user_register", data_type: "number"},
{metric: "api_user_login", data_type: "number"},
{metric: "api_user_delete", data_type: "number"},
], ],
}, {
title: "Filesystem API", expanded: false,
graphs: [
{metric: "filesystem_write", data_type: "number"},
{metric: "filesystem_write_size", data_type: "bytes"},
{metric: "filesystem_read", data_type: "number"},
{metric: "filesystem_read_size", data_type: "bytes"},
{metric: "filesystem_index_update", data_type: "number"},
{metric: "filesystem_index_update_duration", data_type: "duration"},
{
metric: "filesystem_index_update_duration_avg",
agg_base: "filesystem_index_update_duration",
agg_divisor: "filesystem_index_update",
data_type: "duration",
},
]
}, { }, {
title: "Task scheduler", expanded: false, title: "Task scheduler", expanded: false,
graphs: [ graphs: [

View File

@@ -2,6 +2,8 @@
import { flip } from "svelte/animate"; import { flip } from "svelte/animate";
import { formatDataVolume } from "util/Formatting"; import { formatDataVolume } from "util/Formatting";
import SortButton from "layout/SortButton.svelte"; import SortButton from "layout/SortButton.svelte";
import { loading_run } from "lib/Loading";
import { admin_decommission_node } from "lib/AdminAPI";
let { let {
peers = $bindable([]) peers = $bindable([])
@@ -33,6 +35,11 @@ let {
}[] }[]
} = $props(); } = $props();
// If not all peers are online we show the decommission button
let all_reachable = $derived(peers.reduce((acc, val) => {
return val.reachable ? acc : false
}, true))
$effect(() => { $effect(() => {
for (let peer of peers) { for (let peer of peers) {
peer.avg_network_total = peer.avg_network_tx + peer.avg_network_rx peer.avg_network_total = peer.avg_network_tx + peer.avg_network_rx
@@ -74,6 +81,16 @@ let sort = (field: string) => {
}) })
peers = peers peers = peers
} }
const decommission = (id: string) => {
loading_run(async () => {
try {
await admin_decommission_node(id)
} catch (err) {
alert(JSON.stringify(err))
}
})
}
</script> </script>
<div class="table_scroll"> <div class="table_scroll">
@@ -92,16 +109,17 @@ let sort = (field: string) => {
<td><SortButton field="cache_threshold" active_field={sort_field} asc={asc} sort_func={sort}>CThresh</SortButton></td> <td><SortButton field="cache_threshold" active_field={sort_field} asc={asc} sort_func={sort}>CThresh</SortButton></td>
<td><SortButton field="free_space" active_field={sort_field} asc={asc} sort_func={sort}>Free</SortButton></td> <td><SortButton field="free_space" active_field={sort_field} asc={asc} sort_func={sort}>Free</SortButton></td>
<td><SortButton field="min_free_space" active_field={sort_field} asc={asc} sort_func={sort}>Min free</SortButton></td> <td><SortButton field="min_free_space" active_field={sort_field} asc={asc} sort_func={sort}>Min free</SortButton></td>
{#if !all_reachable}<td></td>{/if}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each peers as peer (peer.ip)} {#each peers as peer (peer.id)}
<tr style="border: none;" <tr style="border: none;"
class:highlight_red={!peer.reachable} class:highlight_red={!peer.reachable}
class:highlight_yellow={peer.free_space < peer.min_free_space / 2} class:highlight_yellow={peer.free_space < peer.min_free_space / 2}
class:highlight_blue={peer.free_space < peer.min_free_space} class:highlight_blue={peer.free_space < peer.min_free_space}
class:highlight_green={peer.reachable} class:highlight_green={peer.reachable}
animate:flip={{duration: 1000}} animate:flip={{duration: 500}}
> >
<td>{peer.hostname}</td> <td>{peer.hostname}</td>
<td>{peer.unreachable_count}</td> <td>{peer.unreachable_count}</td>
@@ -115,6 +133,13 @@ let sort = (field: string) => {
<td>{formatDataVolume(peer.cache_threshold, 3)}</td> <td>{formatDataVolume(peer.cache_threshold, 3)}</td>
<td>{formatDataVolume(peer.free_space, 3)}</td> <td>{formatDataVolume(peer.free_space, 3)}</td>
<td>{formatDataVolume(peer.min_free_space, 3)}</td> <td>{formatDataVolume(peer.min_free_space, 3)}</td>
{#if !all_reachable}
<td>
<button class="button flat delete" onclick={() => decommission(peer.id)}>
<i class="icon small">delete</i>
</button>
</td>
{/if}
</tr> </tr>
{/each} {/each}
@@ -135,6 +160,7 @@ let sort = (field: string) => {
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.cache_threshold, 0), 4)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.cache_threshold, 0), 4)}</td>
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.free_space, 0), 4)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.free_space, 0), 4)}</td>
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.min_free_space, 0), 3)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.min_free_space, 0), 3)}</td>
{#if !all_reachable}<td></td>{/if}
</tr> </tr>
<tr> <tr>
<td>Average</td> <td>Average</td>
@@ -153,6 +179,7 @@ let sort = (field: string) => {
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.cache_threshold, 0) / peers.length, 4)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.cache_threshold, 0) / peers.length, 4)}</td>
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.free_space, 0) / peers.length, 4)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.free_space, 0) / peers.length, 4)}</td>
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.min_free_space, 0) / peers.length, 3)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.min_free_space, 0) / peers.length, 3)}</td>
{#if !all_reachable}<td></td>{/if}
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -167,4 +194,7 @@ let sort = (field: string) => {
tr { tr {
text-align: initial; text-align: initial;
} }
.delete {
margin: 0;
}
</style> </style>

View File

@@ -36,7 +36,7 @@ onMount(() => {
{:else if node.is_shared()} {:else if node.is_shared()}
<i class="icon small">share</i> <i class="icon small">share</i>
{/if} {/if}
<div class="node_name" class:base={$nav.base_index === i}> <div class="node_name" class:base={$nav.base_index === i} class:small={$nav.path.length > 10}>
{node.name} {node.name}
</div> </div>
</a> </a>
@@ -64,6 +64,9 @@ onMount(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.small {
max-width: 10vw;
}
.base { .base {
/* The base name uses all available space */ /* The base name uses all available space */
max-width: unset; max-width: unset;

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { run } from 'svelte/legacy';
import Chart from "util/Chart.svelte"; import Chart from "util/Chart.svelte";
import { formatDataVolume, formatDate, formatThousands } from "util/Formatting"; import { formatDataVolume, formatDate, formatThousands } from "util/Formatting";
import Modal from "util/Modal.svelte"; import Modal from "util/Modal.svelte";
@@ -19,12 +18,13 @@ let {
export const toggle = () => visible = !visible export const toggle = () => visible = !visible
const visibility_change = visible => { $effect(() => {
// Only update the chart if the details modal is open. Else the chart is
// rendered for nothing
if (visible) { if (visible) {
update_chart(nav.base, 0, 0) update_chart($nav.base, chart_timespan, chart_interval)
} }
} })
let chart: Chart = $state() let chart: Chart = $state()
let chart_timespan = $state(0) let chart_timespan = $state(0)
@@ -134,15 +134,9 @@ let update_chart = async (base: FSNode, timespan: number, interval: number) => {
console.error("Failed to get time series data:", err) console.error("Failed to get time series data:", err)
} }
} }
run(() => {
visibility_change(visible)
});
let direct_url = $derived($nav.base.path ? window.location.origin+fs_path_url($nav.base.path) : "") let direct_url = $derived($nav.base.path ? window.location.origin+fs_path_url($nav.base.path) : "")
let share_url = $derived(fs_share_url($nav.path)) let share_url = $derived(fs_share_url($nav.path))
let direct_share_url = $derived(fs_share_hotlink_url($nav.path)) let direct_share_url = $derived(fs_share_hotlink_url($nav.path))
run(() => {
update_chart($nav.base, chart_timespan, chart_interval)
});
</script> </script>
<Modal bind:visible={visible} title="Details" width={($nav.base.type === "file" ? 1000 : 750) + "px"}> <Modal bind:visible={visible} title="Details" width={($nav.base.type === "file" ? 1000 : 750) + "px"}>
@@ -172,13 +166,6 @@ run(() => {
<td>Mode</td> <td>Mode</td>
<td>{$nav.base.mode_string}</td> <td>{$nav.base.mode_string}</td>
</tr> </tr>
<tr>
<td>Node ID</td>
<td>
<CopyButton text={$nav.base.id}>Copy</CopyButton>
<a href="/d/{$nav.base.id}">{$nav.base.id}</a>
</td>
</tr>
{#if $nav.base.type === "file"} {#if $nav.base.type === "file"}
<tr> <tr>
<td>File type</td> <td>File type</td>
@@ -201,7 +188,23 @@ run(() => {
</td> </td>
</tr> </tr>
<tr><td>SHA256 sum</td><td>{$nav.base.sha256_sum}</td></tr> <tr><td>SHA256 sum</td><td>{$nav.base.sha256_sum}</td></tr>
{:else if $nav.base.type === "dir"}
<tr>
<td>Total directory size</td>
<td>{formatDataVolume($nav.base.recursive_size, 4)}</td>
</tr>
<tr>
<td>Total directory children</td>
<td>{formatThousands($nav.base.recursive_children)}</td>
</tr>
{/if} {/if}
<tr>
<td>Node ID</td>
<td>
<CopyButton text={$nav.base.id}>Copy</CopyButton>
<a href="/d/{$nav.base.id}">{$nav.base.id}</a>
</td>
</tr>
<tr> <tr>
<td>Direct link</td> <td>Direct link</td>
<td> <td>

View File

@@ -39,6 +39,12 @@ onMount(() => {
<p> <p>
This content has been removed for breaking the law. This content has been removed for breaking the law.
</p> </p>
{:else if $nav.navigation_error === "out_of_transfer"}
<h1>Traffic limit exceeded</h1>
<p>
The host user has exceeded the traffic limit of their subscription.
They need to upgrade to a higher subscription tier.
</p>
{:else} {:else}
<h1>Unknown error code</h1> <h1>Unknown error code</h1>
<p> <p>

View File

@@ -270,9 +270,14 @@ const sort_children = (children: FSNode[], field: string, asc: boolean) => {
[a, b] = [b, a] [a, b] = [b, a]
} }
// If the two values are equal then we force sort by name, since names if (field === "size") {
// are always unique // When doing a size comparison we also take the recursive directory
if (a[field] === b[field]) { // size into account
return (a.type === "dir" ? a.recursive_size : a.file_size) -
(b.type === "dir" ? b.recursive_size : b.file_size)
} else if (a[field] === b[field]) {
// If the two values are equal then we force sort by name, since
// names are always unique
return a.name.localeCompare(b.name, undefined, { numeric: true }) return a.name.localeCompare(b.name, undefined, { numeric: true })
} else if (typeof (a[field]) === "number") { } else if (typeof (a[field]) === "number") {
// Sort ints from high to low // Sort ints from high to low

View File

@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { FSNavigator } from "./FSNavigator"; import type { FSNavigator } from "./FSNavigator";
import { fs_node_icon, fs_share_hotlink_url, fs_share_url, fs_update, type FSNode, type FSPermissions } from "lib/FilesystemAPI.svelte"; import { fs_check_is_error, fs_node_icon, fs_share_hotlink_url, fs_share_url, fs_update, type FSNode, type FSPermissions } from "lib/FilesystemAPI.svelte";
import { copy_text } from "util/Util";
import CopyButton from "layout/CopyButton.svelte"; import CopyButton from "layout/CopyButton.svelte";
import Dialog from "layout/Dialog.svelte"; import Dialog from "layout/Dialog.svelte";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { get_user } from "lib/NovaAPI";
import Button from "layout/Button.svelte";
let { nav }: { let { nav }: {
nav: FSNavigator; nav: FSNavigator;
@@ -12,20 +13,38 @@ let { nav }: {
let path: FSNode[] let path: FSNode[]
let base: FSNode = $state() let base: FSNode = $state()
let toast = $state("")
let share_url = $state("") let share_url = $state("")
let direct_share_url = $state("") let direct_share_url = $state("")
let is_parent = $state(false)
let parent_node: FSNode = $state() let parent_node: FSNode = $state()
let dialog: Dialog = $state() let dialog: Dialog = $state()
let dialog_not_allowed: Dialog = $state()
export const open = async (e: MouseEvent, p: FSNode[]) => { export const open = async (e: MouseEvent, p: FSNode[]) => {
path = p path = p
base = path[path.length-1] base = path[path.length-1]
const user = await get_user()
if (user.subscription.file_sharing === false && base.created_by === user.id) {
}
share_url = fs_share_url(path) share_url = fs_share_url(path)
if (share_url === "") { if (share_url === "") {
// File is not public, make public // File is not public, make public
try {
await make_public() await make_public()
} catch (err) {
if (fs_check_is_error(err, "sharing_not_allowed")) {
if (e.target instanceof HTMLButtonElement) {
dialog_not_allowed.open(e.target.getBoundingClientRect())
} else {
dialog_not_allowed.open((e.target as HTMLElement).parentElement.getBoundingClientRect())
}
return
} else {
alert("Error file making file public: "+err)
}
}
} }
await share() await share()
@@ -50,15 +69,19 @@ const make_public = async () => {
} }
} }
let share_parent = $state(false)
let navigator_share = $state(false)
const share = async () => { const share = async () => {
// If the base node does not have a public ID then the file is shared // If the base node is not shared then the file is shared through a parent
// through a parent node. In that case we ask the user if it was their // node. In that case we ask the user if it was their intention to share the
// intention to share the parent directory // parent directory
is_parent = base.id === undefined share_parent = !base.is_shared()
if (is_parent) { navigator_share = navigator.share !== undefined
if (!base.is_shared()) {
// Walk path backwards looking for the last public ID // Walk path backwards looking for the last public ID
for (let i = path.length - 1; i >= 0; i--) { for (let i = path.length - 1; i >= 0; i--) {
if (path[i].id !== undefined && path[i].id !== "me") { if (path[i].is_shared()) {
parent_node = path[i] parent_node = path[i]
break break
} }
@@ -67,35 +90,35 @@ const share = async () => {
share_url = fs_share_url(path) share_url = fs_share_url(path)
direct_share_url = fs_share_hotlink_url(path) direct_share_url = fs_share_hotlink_url(path)
}
try { const nav_share = async (url: string) => {
await navigator.share({ await navigator.share({
title: base.name, title: base.name,
text: "I would like to share '" + base.name + "' with you", text: "I would like to share '" + base.name + "' with you",
url: share_url, url: url,
}) })
} catch(_) {
if (copy_text(share_url)) {
toast = "Link copied to clipboard"
setTimeout(() => {toast = ""}, 10000)
} else {
alert("Could not copy text")
}
}
} }
</script> </script>
<Dialog bind:this={dialog_not_allowed}>
<div class="dialog_inner">
<div class="highlight_yellow" transition:fade>
Sharing is not allowed on free accounts. Upgrade to premium to
enable this feature
</div>
</div>
</Dialog>
<Dialog bind:this={dialog}> <Dialog bind:this={dialog}>
<div class="dialog_inner"> <div class="dialog_inner">
{#if toast !== ""}
<div class="highlight_green" transition:fade>{toast}</div>
<div class="separator" transition:fade></div>
{/if}
<div>Sharing link</div> <div>Sharing link</div>
<div class="link_copy"> <div class="link_copy">
<div class="button_container"> <div class="button_container">
<CopyButton text={share_url}>Copy</CopyButton> <CopyButton text={share_url}>Copy</CopyButton>
{#if navigator_share}
<Button icon="share" label="Share" click={() => {nav_share(share_url)}}/>
{/if}
</div> </div>
<a href="{share_url}">{share_url}</a> <a href="{share_url}">{share_url}</a>
</div> </div>
@@ -104,11 +127,14 @@ const share = async () => {
<div class="link_copy"> <div class="link_copy">
<div class="button_container"> <div class="button_container">
<CopyButton text={direct_share_url}>Copy</CopyButton> <CopyButton text={direct_share_url}>Copy</CopyButton>
{#if navigator_share}
<Button icon="share" label="Share" click={() => {nav_share(direct_share_url)}}/>
{/if}
</div> </div>
<a href="{direct_share_url}">{direct_share_url}</a> <a href="{direct_share_url}">{direct_share_url}</a>
</div> </div>
{#if is_parent} {#if share_parent}
<div class="separator"></div> <div class="separator"></div>
<div> <div>
This link also gives access to This link also gives access to

View File

@@ -40,7 +40,6 @@ export const copy_link = () => {
<FileStats nav={nav} bind:expanded={toolbar_expanded}/> <FileStats nav={nav} bind:expanded={toolbar_expanded}/>
<div class="grid" class:hide={!toolbar_expanded}> <div class="grid" class:hide={!toolbar_expanded}>
<div class="button_row"> <div class="button_row">
<button onclick={() => {nav.open_sibling(-1)}}> <button onclick={() => {nav.open_sibling(-1)}}>
<i class="icon">skip_previous</i> <i class="icon">skip_previous</i>
@@ -73,7 +72,11 @@ export const copy_link = () => {
{#if path_is_shared($nav.path)} {#if path_is_shared($nav.path)}
<button onclick={copy_link} class:button_highlight={link_copied}> <button onclick={copy_link} class:button_highlight={link_copied}>
<i class="icon">content_copy</i> <i class="icon">content_copy</i>
{#if link_copied}
<span>Link copied!</span>
{:else}
<span><u>C</u>opy link</span> <span><u>C</u>opy link</span>
{/if}
</button> </button>
{/if} {/if}

View File

@@ -5,9 +5,8 @@ import BrandingOptions from "./BrandingOptions.svelte";
import { branding_from_props } from "./Branding"; import { branding_from_props } from "./Branding";
import FileOptions from "./FileOptions.svelte"; import FileOptions from "./FileOptions.svelte";
import SharingOptions from "./SharingOptions.svelte"; import SharingOptions from "./SharingOptions.svelte";
import AccessControl from "./AccessControl.svelte";
import type { FSNavigator } from "filesystem/FSNavigator"; import type { FSNavigator } from "filesystem/FSNavigator";
import { loading_finish, loading_start } from "lib/Loading"; import { loading_run } from "lib/Loading";
let file: FSNode = $state({} as FSNode) let file: FSNode = $state({} as FSNode)
let options: NodeOptions = $state({} as NodeOptions) let options: NodeOptions = $state({} as NodeOptions)
@@ -73,23 +72,22 @@ const save = async (e: SubmitEvent) => {
e.preventDefault() e.preventDefault()
console.debug("Saving file", file.path) console.debug("Saving file", file.path)
let new_file: FSNode
try { try {
loading_start() await loading_run(async () => {
options.branding_enabled = JSON.stringify(branding_enabled) options.branding_enabled = JSON.stringify(branding_enabled)
new_file = await fs_update(file.path, options) await fs_update(file.path, options)
// If the file name has changed we need to call fs_rename. This is a
// separate operation because a rename also moves the file
if (new_name !== file.name) { if (new_name !== file.name) {
let parent = file.path.slice(0, -file.name.length) let parent = file.path.slice(0, -file.name.length)
console.log("Moving", file.path, "to", parent+new_name) console.log("Moving", file.path, "to", parent+new_name)
await fs_rename(file.path, parent+new_name) await fs_rename(file.path, parent+new_name)
file.path = parent+new_name file.path = parent+new_name
new_file.name = new_name
new_file.path = file.path
} }
})
} catch (err) { } catch (err) {
if (err.message) { if (err.message) {
alert(err.message) alert(err.message)
@@ -98,11 +96,11 @@ const save = async (e: SubmitEvent) => {
alert(err) alert(err)
} }
return return
} finally {
loading_finish()
} }
if (open_after_edit) { // The navigate call will be debounced if the new path is equal to the
// current path. So we call reload() instead if that's the case
if (open_after_edit && $nav.base.path !== file.path) {
nav.navigate(file.path, false) nav.navigate(file.path, false)
} else { } else {
nav.reload() nav.reload()
@@ -120,12 +118,6 @@ const save = async (e: SubmitEvent) => {
<i class="icon">share</i> <i class="icon">share</i>
Sharing Sharing
</button> </button>
{#if $nav.permissions.owner}
<button class:button_highlight={tab === "access"} onclick={() => tab = "access"}>
<i class="icon">key</i>
Access control
</button>
{/if}
<button class:button_highlight={tab === "branding"} onclick={() => tab = "branding"}> <button class:button_highlight={tab === "branding"} onclick={() => tab = "branding"}>
<i class="icon">palette</i> <i class="icon">palette</i>
Branding Branding
@@ -148,8 +140,6 @@ const save = async (e: SubmitEvent) => {
bind:file bind:file
bind:options bind:options
/> />
{:else if tab === "access"}
<AccessControl bind:options />
{:else if tab === "branding"} {:else if tab === "branding"}
<BrandingOptions <BrandingOptions
bind:enabled={branding_enabled} bind:enabled={branding_enabled}

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { run } from 'svelte/legacy';
import { domain_url } from "util/Util"; import { domain_url } from "util/Util";
import CopyButton from "layout/CopyButton.svelte"; import CopyButton from "layout/CopyButton.svelte";
import { formatDate } from "util/Formatting"; import { formatDate } from "util/Formatting";
import { type FSNode, type NodeOptions } from "lib/FilesystemAPI.svelte"; import { type FSNode, type NodeOptions } from "lib/FilesystemAPI.svelte";
import AccessControl from "./AccessControl.svelte"; import AccessControl from "./AccessControl.svelte";
import { user } from "lib/UserStore";
let { let {
file = $bindable(), file = $bindable(),
@@ -14,27 +14,24 @@ let {
options: NodeOptions; options: NodeOptions;
} = $props(); } = $props();
let embed_html: string = $state() let is_shared = $derived(file.is_shared() || options.link_permissions?.read)
let preview_area: HTMLDivElement = $state() let embed_html: string = $derived.by(() => {
if (!is_shared) {
const embed_iframe = (file: FSNode, options: NodeOptions) => { return "File is not shared, can't generate embed code"
if (!file.is_shared()) {
example = false
embed_html = "File is not shared, can't generate embed code"
return
} }
let url = domain_url()+"/d/"+file.id let url = domain_url()+"/d/"+file.id
embed_html = `<iframe ` + return `<iframe ` +
`src="${url}" ` + `src="${url}" ` +
`style="border: none; width: 100%; max-width 90vw; height: 800px; max-height: 75vh; border-radius: 6px;" ` + `style="border: none; width: 100%; max-width 90vw; height: 800px; max-height: 75vh; border-radius: 6px;" ` +
`allowfullscreen` + `allowfullscreen` +
`></iframe>` `></iframe>`
} })
let preview_area: HTMLDivElement = $state()
let example = $state(false) let example = $state(false)
const toggle_example = () => { const toggle_example = () => {
if (file.is_shared()) { if (is_shared) {
example = !example example = !example
if (example) { if (example) {
preview_area.innerHTML = embed_html preview_area.innerHTML = embed_html
@@ -45,11 +42,15 @@ const toggle_example = () => {
} }
let share_link = $derived(window.location.protocol+"//"+window.location.host+"/d/"+file.id) let share_link = $derived(window.location.protocol+"//"+window.location.host+"/d/"+file.id)
run(() => {
embed_iframe(file, options)
});
</script> </script>
{#if $user.subscription.file_sharing === false}
<div class="highlight_yellow">
Your account does not support sharing features. Access controls will not
have any effect until you upgrade to premium.
</div>
{/if}
<fieldset> <fieldset>
<legend>Public link</legend> <legend>Public link</legend>
@@ -85,8 +86,8 @@ run(() => {
<div class="center"> <div class="center">
<textarea bind:value={embed_html} style="width: 100%; height: 4em;"></textarea> <textarea bind:value={embed_html} style="width: 100%; height: 4em;"></textarea>
<br/> <br/>
<CopyButton text={embed_html}>Copy HTML</CopyButton> <CopyButton text={embed_html} disabled={!is_shared}>Copy HTML</CopyButton>
<button onclick={toggle_example} class:button_highlight={example} disabled={!file.is_shared()}> <button onclick={toggle_example} class:button_highlight={example} disabled={!is_shared}>
<i class="icon">visibility</i> Show example <i class="icon">visibility</i> Show example
</button> </button>
</div> </div>

View File

@@ -86,6 +86,14 @@ const themes = [
brand_background_color: "#1e1e2e", brand_background_color: "#1e1e2e",
brand_body_color: "#181825", brand_body_color: "#181825",
brand_card_color: "#313244", brand_card_color: "#313244",
}, {
name: "Dracula",
brand_input_color: "#424450",
brand_highlight_color: "#50FA7B",
brand_danger_color: "#FF5555",
brand_background_color: "#191A21",
brand_body_color: "#21222C",
brand_card_color: "#343746",
}, { }, {
name: "Nova", name: "Nova",
brand_input_color: "#28033f", brand_input_color: "#28033f",

View File

@@ -101,21 +101,6 @@ const navigate_back = () => {
// Deletion function // Deletion function
const delete_selected = async () => { const delete_selected = async () => {
let count = nav.children.reduce((acc, cur) => {
if (cur.fm_selected) {
acc++
}
return acc
}, 0)
let confirmSingle = `Are you sure you want to delete this file? This action is irreversible.`
let confirmMulti = `Are you sure you want to delete these ${count} files? This action is irreversible.`
if (count === 0 ||
(count === 1 && !confirm(confirmSingle)) ||
(count > 1 && !confirm(confirmMulti))) {
return
}
try { try {
loading_start() loading_start()

View File

@@ -1,9 +1,13 @@
<script lang="ts"> <script lang="ts">
import { formatDataVolume } from "util/Formatting"; import { formatDataVolume, formatDate, formatThousands } from "util/Formatting";
import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte" import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte"
import type { FSNavigator } from "filesystem/FSNavigator"; import type { FSNavigator } from "filesystem/FSNavigator";
import SortButton from "layout/SortButton.svelte"; import SortButton from "layout/SortButton.svelte";
import { FileAction, type FileActionHandler } from "./FileManagerLib"; import { FileAction, type FileActionHandler } from "./FileManagerLib";
import Dialog from "layout/Dialog.svelte";
import Button from "layout/Button.svelte";
import { onMount } from "svelte";
import CopyButton from "layout/CopyButton.svelte";
let { let {
nav, nav,
@@ -20,23 +24,70 @@ let {
hide_edit?: boolean hide_edit?: boolean
hide_branding?: boolean hide_branding?: boolean
} = $props(); } = $props();
let dialog: Dialog = $state()
const columns: {field: string, label: string}[] = [
{field: "modified", label: "Modified on"},
{field: "created", label: "Created on"},
{field: "created_by", label: "Created by"},
{field: "mode", label: "Mode"},
{field: "recursive_children", label: "Children"},
{field: "file_type", label: "File type"},
{field: "size", label: "Size"},
{field: "id", label: "Node ID"},
]
let config: {
modified?: boolean
created?: boolean
created_by?: boolean
mode?: boolean
recursive_children?: boolean
file_type?: boolean
size?: boolean
id?: boolean
} = $state({
size: true, // Only size is visible by default
})
const prevent = (e: MouseEvent) => {
e.stopPropagation()
}
const submit_settings = (e: SubmitEvent) => {
e.preventDefault()
dialog.close()
}
const save_settings = () => {
window.localStorage.setItem("list_view_columns", JSON.stringify(config))
}
const load_settings = () => {
let item = window.localStorage.getItem("list_view_columns")
if (item !== null) {
config = JSON.parse(item)
}
}
onMount(load_settings)
</script> </script>
<table class="directory"> <table class="directory">
<thead> <thead>
<tr> <tr class="header_row">
<td></td> <td></td>
<td> <td>
<SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="name"> <SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="name">
Name Name
</SortButton> </SortButton>
</td> </td>
<td class="hide_small"> {#each columns as col}
<SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="file_size"> <td class="hide_small" class:hidden={!config[col.field]}>
Size <SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field={col.field}>
{col.label}
</SortButton> </SortButton>
</td> </td>
<td></td> {/each}
<td class="node_icons icons_wrap">
<Button flat icon="settings" click={e => dialog.open(e)} />
</td>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -56,11 +107,35 @@ let {
{child.name} {child.name}
</a> </a>
</td> </td>
<td class="node_size hide_small"> <td class="node_size hide_small" class:hidden={!config.modified}>
{formatDate(child.modified, true, true, false)}
</td>
<td class="node_size hide_small" class:hidden={!config.created}>
{formatDate(child.created, true, true, false)}
</td>
<td class="node_size hide_small" class:hidden={!config.created_by}>
{child.created_by}
</td>
<td class="node_size hide_small" class:hidden={!config.mode}>
{child.mode_string}
</td>
<td class="node_size hide_small" class:hidden={!config.recursive_children}>
{formatThousands(child.recursive_children)}
</td>
<td class="node_size hide_small" class:hidden={!config.file_type}>
{child.file_type}
</td>
<td class="node_size hide_small" class:hidden={!config.size}>
{#if child.type === "file"} {#if child.type === "file"}
{formatDataVolume(child.file_size, 3)} {formatDataVolume(child.file_size, 3)}
{:else if child.type === "dir"}
{formatDataVolume(child.recursive_size, 3)}
{/if} {/if}
</td> </td>
<td class="node_size hide_small" class:hidden={!config.id}>
<CopyButton text={$nav.base.id} onclick={prevent} />
<a href="/d/{child.id}" onclick={prevent}>{child.id}</a>
</td>
<td class="node_icons"> <td class="node_icons">
<div class="icons_wrap"> <div class="icons_wrap">
{#if child.abuse_type !== undefined} {#if child.abuse_type !== undefined}
@@ -91,15 +166,39 @@ let {
</tbody> </tbody>
</table> </table>
<Dialog bind:this={dialog} onclose={save_settings}>
<form class="column_config" onsubmit={submit_settings}>
{#each columns as col}
<div>
<input type="checkbox" id="show_{col.field}" name="show_{col.field}" bind:checked={config[col.field]}/>
<label for="show_{col.field}">{col.label}</label>
</div>
{/each}
<button type="submit">
<i class="icon">save</i>
Save
</button>
</form>
</Dialog>
<style> <style>
.directory { .directory {
background: var(--body_background); background: var(--body_background);
border-collapse: collapse; border-collapse: collapse;
/* backdrop-filter: blur(4px); */ backdrop-filter: blur(4px);
max-width: 1200px; max-width: 1200px;
margin: auto; /* center */ margin: auto; /* center */
} }
td { td {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
.header_row {
white-space: nowrap;
}
.node_icons {
padding: 0; padding: 0;
} }
.node { .node {
@@ -151,6 +250,12 @@ td {
display: none; display: none;
} }
.column_config {
display: flex;
flex-direction: column;
white-space: nowrap;
}
/* Large icon mode only supported on wide screens */ /* Large icon mode only supported on wide screens */
@media (min-width: 500px) { @media (min-width: 500px) {
.node_icon.large_icons { .node_icon.large_icons {

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 {
loading_start();
entries = await fs_get_trash(root_path) entries = await fs_get_trash(root_path)
error = ""; error = "";
} catch (err: any) { } catch (err: any) {
error = err?.message ?? JSON.stringify(err); error = err?.message ?? JSON.stringify(err);
} finally {
loaded = true;
loading_finish();
} }
})
}; };
const delete_entry = async (entry: TrashEntry) => { const delete_entry = async (entry: TrashEntry) => {
@@ -76,30 +79,46 @@ 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}
<p class="empty">The trash is empty.</p>
{:else if entries.length > 0} {:else if entries.length > 0}
<table class="trash_table"> <table class="trash_table">
<thead> <thead>
@@ -107,7 +126,7 @@ onMount(load);
<td></td> <td></td>
<td>Name</td> <td>Name</td>
<td class="hide_small">Original location</td> <td class="hide_small">Original location</td>
<td class="hide_small">Deleted</td> <td class="hide_small">Deleted at</td>
<td class="hide_small">Size</td> <td class="hide_small">Size</td>
<td></td> <td></td>
</tr> </tr>
@@ -127,23 +146,23 @@ onMount(load);
{entry.node.name} {entry.node.name}
</a> </a>
</td> </td>
<td class="hide_small original_path"> <td class="hide_small">
{entry.original_path || "—"} {entry.original_path ? entry.original_path.slice(0, -entry.node.name.length) : "—"}
</td> </td>
<td class="hide_small date_cell"> <td class="hide_small">
{entry.deletion_date ? formatDate(entry.deletion_date) : "—"} {entry.deletion_date ? formatDate(entry.deletion_date) : "—"}
</td> </td>
<td class="hide_small size_cell"> <td class="hide_small">
{#if entry.node.type === "file"} {#if entry.node.type === "file"}
{formatDataVolume(entry.node.file_size, 3)} {formatDataVolume(entry.node.file_size, 3)}
{/if} {/if}
</td> </td>
<td class="button_cell"> <td>
<div class="row_actions"> <div class="row_actions">
<button title="Restore to original location" onclick={() => restore_entry(entry)}> <button title="Restore to original location" onclick={() => restore_entry(entry)}>
<i class="icon">restore_from_trash</i> <i class="icon">restore_from_trash</i>
</button> </button>
<button class="danger" title="Delete permanently" onclick={() => delete_entry(entry)}> <button class="button button_red" title="Delete permanently" onclick={() => delete_entry(entry)}>
<i class="icon">delete_forever</i> <i class="icon">delete_forever</i>
</button> </button>
</div> </div>
@@ -153,45 +172,19 @@ onMount(load);
</tbody> </tbody>
</table> </table>
{/if} {/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

@@ -91,7 +91,8 @@ onMount(() => {
</div> </div>
<h2>Tracklist</h2> <h2>Tracklist</h2>
{#each siblings as sibling (sibling.path)} {#each siblings as sibling (sibling.id)}
{#if !sibling.is_hidden()}
<a href={"/d"+fs_encode_path(sibling.path)} class="node" class:playing={sibling.path === $nav.base.path}> <a href={"/d"+fs_encode_path(sibling.path)} class="node" class:playing={sibling.path === $nav.base.path}>
{#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>
@@ -101,6 +102,7 @@ onMount(() => {
<span>{sibling.name}</span> <span>{sibling.name}</span>
<br/> <br/>
</a> </a>
{/if}
{/each} {/each}
</TextBlock> </TextBlock>
</div> </div>

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

@@ -22,10 +22,9 @@ import Euro from "util/Euro.svelte";
<Checkout/> <Checkout/>
{:else} {:else}
<p> <p>
You are currently logged in as '{$user.username}'. Use the Pick one of the subscription plans above to upgrade your
form below to activate prepaid on this account. account to premium.
</p> </p>
<Checkout/>
{/if} {/if}
</section> </section>
</div> </div>

View File

@@ -28,49 +28,23 @@ let upload_widget
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>
</section> <h2>Features</h2>
</div>
<header>
<h1>Features</h1>
</header>
<div class="page_content">
<section>
<ul> <ul>
<li> <li>
<span class="bold">Unlimited</span> storage space Cloud storage with <a href="/filesystem#toc_7">rclone</a> and
FTPS support
</li> </li>
<li> <li>
Cloud storage with <a href="/filesystem#toc_7">rclone</a> and <a Customizable download pages (custom branding colours, header and
href="/filesystem#toc_10">FTPS</a> support background images)
</li> </li>
<li> <li>
<span class="bold">Customizable</span> download pages (custom Fault tolerance for up to four storage server failures
branding colours, header and background images)
</li> </li>
<li> <li>
<span class="bold">Fault tolerance</span> for up to four storage Caching servers highly optimized for long-distance data transfer
server failures
</li>
<li>
<span class="bold">1.8 Terabits per second</span> of total bandwidth
capacity
</li>
<li>
Twenty-three high-performance caching servers optimized for data
transfer over long distances
</li>
<li>
File caching in
<span class="bold">Portland</span>,
<span class="bold">Querétaro</span>,
<span class="bold">New York</span>,
<span class="bold">São Paulo</span>,
<span class="bold">Frankfurt</span> and
<span class="bold">Singapore</span>
</li> </li>
</ul> </ul>
<img class="image" src="/res/img/map.webp" alt="A globe showing datacenter locations"/>
<h3>What you don't get</h3> <h3>What you don't get</h3>
<ul> <ul>
<li> <li>
@@ -78,10 +52,7 @@ let upload_widget
in case of emergency in case of emergency
</li> </li>
<li> <li>
Commercial support - If you have a question, please check the <a Commercial support - I'm busy, please don't bother me
href="/about">Q&A page</a> first. If that doesn't answer it, try
the <a href="https://discord.gg/UDjaBGwr4p"
target="_blank">support channel</a> on Discord.
</li> </li>
</ul> </ul>
</section> </section>
@@ -142,43 +113,8 @@ header > h1 {
background-size: contain; background-size: contain;
background-position: center; background-position: center;
} }
.image {
max-width: 100%;
border-radius: 12px;
}
.bold { .bold {
font-weight: bold; font-weight: bold;
color: var(--highlight_color); color: var(--highlight_color);
} }
.prices {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.prices > div {
flex: 1 0 200px;
min-width: 200px;
display: flex;
flex-direction: row;
text-align: center;
border-radius: 6px;
overflow: hidden;
border: 2px solid var(--card_color);
}
.prices > div > div {
flex: 1 1 auto;
padding: 4px;
font-size: 1.3em;
}
.prices > div > div:nth-child(2) {
background: var(--card_color);
}
@media(max-width: 1000px) {
.prices > div {
flex-direction: column;
}
}
</style> </style>

View File

@@ -137,8 +137,8 @@ const plans: Plan[] = [
<div class="feat_table"> <div class="feat_table">
{#each plans as plan (plan.title)} {#each plans as plan (plan.title)}
<div class="plan"> <div class="plan">
<div class="title"> <div>
<span class="bold">{plan.title}</span> <span class="title">{plan.title}</span>
<br/> <br/>
<Euro amount={plan.monthly}/> / month <Euro amount={plan.monthly}/> / month
</div> </div>
@@ -212,6 +212,9 @@ const plans: Plan[] = [
word-wrap: break-word; word-wrap: break-word;
hyphens: auto; hyphens: auto;
} }
.title {
font-size: 1.5em;
}
.plan > .order { .plan > .order {
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;

View File

@@ -6,20 +6,24 @@ let {
style = "", style = "",
large_icon = false, large_icon = false,
small_icon = false, small_icon = false,
children disabled = false,
children,
onclick,
}: { }: {
text?: string; text?: string;
style?: string; style?: string;
large_icon?: boolean; large_icon?: boolean;
small_icon?: boolean; small_icon?: boolean;
disabled?: boolean;
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
onclick?: (e: MouseEvent) => void;
} = $props(); } = $props();
let failed = $state(false) let failed = $state(false)
let success = $state(false) let success = $state(false)
// Exported so it can be called manually // Exported so it can be called manually
export const copy = () => { export const copy = (e: MouseEvent) => {
try { try {
copy_text(text) copy_text(text)
success = true success = true
@@ -32,6 +36,10 @@ export const copy = () => {
success = false success = false
failed = false failed = false
}, 10000) }, 10000)
if (onclick !== undefined) {
onclick(e)
}
} }
</script> </script>
@@ -44,7 +52,7 @@ export const copy = () => {
class:large_icon class:large_icon
class:small_icon class:small_icon
title="Copy text to clipboard" title="Copy text to clipboard"
disabled={text === ""} disabled={disabled}
> >
<i class="icon">content_copy</i> <i class="icon">content_copy</i>
<span> <span>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
let { children }: { let { children, onclose }: {
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
onclose?: () => void
} = $props(); } = $props();
let dialog: HTMLDialogElement = $state() let dialog: HTMLDialogElement = $state()
@@ -8,12 +9,12 @@ 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()
const edge_offset = 2 const edge_offset = 10
// Get the egdes of the screen, so the window does not spawn off-screen // Get the egdes of the screen, so the window does not spawn off-screen
const dialog_rect = dialog.getBoundingClientRect() const dialog_rect = dialog.getBoundingClientRect()
const max_left = window.innerWidth - dialog_rect.width - edge_offset const max_left = document.documentElement.clientWidth - dialog_rect.width - edge_offset
const max_top = window.innerHeight - dialog_rect.height - edge_offset const max_top = document.documentElement.clientHeight - dialog_rect.height - edge_offset
let min_left: number, min_top: number let min_left: number, min_top: number
if (origin instanceof DOMRect) { if (origin instanceof DOMRect) {
@@ -35,6 +36,9 @@ export const open = (origin: DOMRect|MouseEvent) => {
export const close = () => { export const close = () => {
dialog.close() dialog.close()
if (onclose !== undefined) {
onclose()
}
} }
// Attach a click handler so that the dialog is closed when the user clicks // Attach a click handler so that the dialog is closed when the user clicks
@@ -44,7 +48,7 @@ export const close = () => {
const click = (e: MouseEvent) => { const click = (e: MouseEvent) => {
if (e.target === dialog) { if (e.target === dialog) {
e.preventDefault() e.preventDefault()
dialog.close() close()
} }
} }
</script> </script>

View File

@@ -4,7 +4,7 @@ let {
active_field = "", active_field = "",
asc = true, asc = true,
sort_func, sort_func,
children children,
}: { }: {
field?: string; field?: string;
active_field?: string; active_field?: string;
@@ -14,7 +14,7 @@ let {
} = $props(); } = $props();
</script> </script>
<button onclick={() => sort_func(field)}> <button onclick={() => sort_func(field)} class="button flat">
{#if active_field === field} {#if active_field === field}
{#if asc}{:else}{/if} {#if asc}{:else}{/if}
{/if} {/if}
@@ -25,8 +25,6 @@ let {
button { button {
display: block; display: block;
margin: 0; margin: 0;
line-height: 1em;
width: 100%; width: 100%;
text-align: initial;
} }
</style> </style>

View File

@@ -1,5 +1,5 @@
import { countries } from "country-data-list" import { countries } from "country-data-list"
import { check_response, get_endpoint } from "./NovaAPI" import { check_response, get_endpoint, type GenericResponse } from "./NovaAPI"
export const country_name = (country: string) => { export const country_name = (country: string) => {
if (country !== "" && countries[country] !== undefined) { if (country !== "" && countries[country] !== undefined) {
@@ -27,3 +27,15 @@ export const get_admin_invoices = async (year: number, month: number) => {
) )
) as Invoice[] ) as Invoice[]
}; };
export const admin_decommission_node = async (peer_id: string) => {
let form = new FormData()
form.set("peer_id", peer_id)
return await check_response(
await fetch(
get_endpoint() + "/admin/decommission_node",
{ method: "POST", body: form }
)
) as GenericResponse
};

View File

@@ -34,6 +34,8 @@ export class FSNode {
mode_string: string mode_string: string
mode_octal: string mode_octal: string
created_by: string created_by: string
recursive_size: number
recursive_children: number
abuse_type?: string abuse_type?: string
abuse_report_time?: string abuse_report_time?: string
@@ -188,7 +190,7 @@ export const fs_get_node = async (path: string) => {
// - shared, boolean. If true the node will receive a public ID // - shared, boolean. If true the node will receive a public ID
// //
// Returns the modified filesystem node object // Returns the modified filesystem node object
export const fs_update = async (path: string, opts: NodeOptions) => { export const fs_update = async (path: string, opts: NodeOptions): Promise<FSNode> => {
const form = new FormData() const form = new FormData()
form.append("action", "update") form.append("action", "update")
@@ -314,6 +316,26 @@ export const fs_check_response = async (resp: Response) => {
return JSON.parse(text) return JSON.parse(text)
} }
export const fs_check_is_error = (err: any, target: string) => {
if (typeof err.value !== "string") {
return false
}
if (err.value === target) {
return true
}
if (err.value === "multiple_errors" && Array.isArray(err.errors)) {
for (const embedded_err of err.errors) {
if (embedded_err.value === target) {
return true
}
}
}
return false
}
export const fs_path_url = (path: string) => { export const fs_path_url = (path: string) => {
if (!path || path.length === 0) { if (!path || path.length === 0) {
return "" return ""

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
user.update((user: User) => {
for (let key of Object.keys(data)) { for (let key of Object.keys(data)) {
cached_user[key] = data[key] 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

@@ -37,7 +37,7 @@ let frac = $derived(used / total)
{#if $user !== null && $user.monthly_transfer_cap > 0} {#if $user !== null && $user.monthly_transfer_cap > 0}
<p> <p>
You have a billshock limit configured. <a You have a billshock limit configured. <a
href="/user/sharing/bandwidth">increase or disable the limit</a> to href="/user/sharing">increase or disable the limit</a> to
continue sharing files. continue sharing files.
</p> </p>
{/if} {/if}
@@ -59,7 +59,7 @@ let frac = $derived(used / total)
{#if $user !== null && $user.monthly_transfer_cap > 0} {#if $user !== null && $user.monthly_transfer_cap > 0}
<p> <p>
You have a billshock limit configured. <a You have a billshock limit configured. <a
href="/user/sharing/bandwidth">increase or disable the limit</a> to href="/user/sharing">increase or disable the limit</a> to
continue sharing files. continue sharing files.
</p> </p>
{/if} {/if}

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",
icon: "share",
subpages: [
{
path: "/user/sharing/bandwidth",
title: "Sharing settings", title: "Sharing settings",
icon: "share", icon: "share",
component: BandwidthSharing, component: SharingSettings,
}, {
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

@@ -7,7 +7,6 @@ import { user } from "lib/UserStore";
import { put_user } from "lib/NovaAPI"; import { put_user } from "lib/NovaAPI";
let subscription: string = $state($user === null ? "" : $user.subscription.id) let subscription: string = $state($user === null ? "" : $user.subscription.id)
let subscription_type: string = $state($user === null ? "" : $user.subscription.type)
let success_message: SuccessMessage = $state() let success_message: SuccessMessage = $state()
const update = async (plan: string) => { const update = async (plan: string) => {
@@ -71,13 +70,6 @@ const update = async (plan: string) => {
<p> <p>
Here you can switch between different subscription plans. Here you can switch between different subscription plans.
</p> </p>
<p>
The Patreon subscription is managed by Patreon. Nova cannot modify
or end your subsciption. If you would like to cancel your Patreon plan
you can do that <a
href="https://www.patreon.com/settings/memberships/nova"
target="_blank">on Patreon</a>.
</p>
<p> <p>
The Prepaid subscription is charged daily based on usage. When you reach The Prepaid subscription is charged daily based on usage. When you reach
negative balance the subscription will automatically end. You need at negative balance the subscription will automatically end. You need at
@@ -91,41 +83,6 @@ const update = async (plan: string) => {
<SuccessMessage bind:this={success_message}/> <SuccessMessage bind:this={success_message}/>
<div class="feat_table"> <div class="feat_table">
<div>
<div class="feat_label" class:feat_highlight={subscription_type === "patreon"}>
Patreon<br/>
{#if subscription_type === "patreon"}
Currently active<br/>
<a class="button" href="https://www.patreon.com/settings/memberships/nova" target="_blank">
<i class="icon">settings</i>
Manage
</a>
{:else}
<a class="button" href="/api/patreon_auth/start">
<i class="icon">add_link</i>
Link Patreon
</a>
{/if}
</div>
<div class="feat_normal round_tr" class:feat_highlight={subscription_type === "patreon"}>
<p>
This subscription is managed by Patreon. You will need to <a
href="https://www.patreon.com/nova/membership"
target="_blank">purchase a plan on Patreon</a> before you
can activate this subscription. After your purchase you can
click the "Link Patreon" button and your account will be
upgraded.
</p>
<ul>
<li>€4 per month</li>
<li>No storage limit for file sharing</li>
<li>4 TB transfer limit (higher plans available)</li>
<li>Access to the <a href="/filesystem">filesystem</a></li>
<li>2 TB filesytem storage limit (higher plans available)</li>
</ul>
</div>
</div>
<div> <div>
<div class="feat_label" class:feat_highlight={subscription === "prepaid"}> <div class="feat_label" class:feat_highlight={subscription === "prepaid"}>
Prepaid<br/> Prepaid<br/>
@@ -141,11 +98,7 @@ const update = async (plan: string) => {
<div class="feat_normal round_tr" class:feat_highlight={subscription === "prepaid"}> <div class="feat_normal round_tr" class:feat_highlight={subscription === "prepaid"}>
<p> <p>
You will need a positive account balance to activate this You will need a positive account balance to activate this
plan. If you currently have a Patreon subscription active, plan. The plan deactivates when your credit runs out.
then enabling prepaid will not cancel that subscription. You
can end your subscription <a
href="https://www.patreon.com/settings/memberships/nova"
target="_blank">on Patreon.com</a>.
</p> </p>
<ul> <ul>
<li> <li>
@@ -157,12 +110,12 @@ const update = async (plan: string) => {
</li> </li>
<li> <li>
€1 per TB for data transfer (with <a €1 per TB for data transfer (with <a
href="/user/sharing/bandwidth">hotlinking</a> href="/user/sharing">hotlinking</a>
enabled) enabled)
</li> </li>
<li> <li>
File <a href="/user/sharing/embedding">embedding File <a href="/user/sharing">embedding control</a>
control</a> options options
</li> </li>
</ul> </ul>
</div> </div>
@@ -177,8 +130,8 @@ const update = async (plan: string) => {
</div> </div>
<div class="feat_normal round_br" class:feat_highlight={subscription === ""}> <div class="feat_normal round_br" class:feat_highlight={subscription === ""}>
<ul> <ul>
<li>Standard free plan, files expire after 120 days.</li> <li>Storage limit: 50 GB</li>
<li>Download limit of 6 GB per day</li> <li>Download limit: 50 GB per 30 days (sliding window)</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -58,6 +58,6 @@ onMount(async () => {
<br/><br/> <br/><br/>
Egress used (30 days): Egress used (30 days):
(<a href="/user/sharing/bandwidth">set custom limit</a>) (<a href="/user/sharing">set custom limit</a>)
<HotlinkProgressBar used={transfer_used} total={transfer_cap}></HotlinkProgressBar> <HotlinkProgressBar used={transfer_used} total={transfer_cap}></HotlinkProgressBar>
{/if} {/if}

View File

@@ -32,7 +32,7 @@ const min_card_size = 0
const def_card_size = 1 const def_card_size = 1
const max_card_size = 3 const max_card_size = 3
const expand = i => { const expand = (i: number) => {
if (cards[i].size === undefined) { if (cards[i].size === undefined) {
cards[i].size = def_card_size cards[i].size = def_card_size
} }
@@ -41,7 +41,7 @@ const expand = i => {
} }
save() save()
} }
const shrink = i => { const shrink = (i: number) => {
if (cards[i].size === undefined) { if (cards[i].size === undefined) {
cards[i].size = def_card_size cards[i].size = def_card_size
} }
@@ -51,7 +51,7 @@ const shrink = i => {
save() save()
} }
const swap_card = (idx1, idx2) => { const swap_card = (idx1: number, idx2: number) => {
const card1 = cards[idx1] const card1 = cards[idx1]
cards[idx1] = cards[idx2] cards[idx1] = cards[idx2]
cards[idx2] = card1 cards[idx2] = card1

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>

View File

@@ -129,7 +129,7 @@ const load_page = async (pathname: string, history: boolean): Promise<boolean> =
return true return true
} }
const click = (e: MouseEvent) => { const click = async (e: MouseEvent) => {
const origin = (e.target as Element).closest("a"); const origin = (e.target as Element).closest("a");
if (origin === null) { if (origin === null) {
@@ -145,7 +145,7 @@ const click = (e: MouseEvent) => {
// Try to load the page, if the page was found we cancel the browser // Try to load the page, if the page was found we cancel the browser
// navigation event so we can handle it ourselves // navigation event so we can handle it ourselves
if (load_page(url.pathname, true)) { if (await load_page(url.pathname, true) === true) {
e.preventDefault() e.preventDefault()
} }
} }
@@ -165,7 +165,7 @@ const popstate = (e: PopStateEvent) => {
{#if current_page !== null} {#if current_page !== null}
<current_page.component /> <current_page.component />
{#if current_page.footer === undefined || current_page.footer === true} {#if current_page.footer !== false}
<Footer/> <Footer/>
{/if} {/if}
{/if} {/if}