- You already have a Patreon subscription active. You cannot use
- Prepaid while that subscription is active.
+ You already have the {$user.subscription.name} plan active.
+ Your account balance is . Subscriptions are
+ charged from this balance every day, use the form below to
+ top it up or to switch to another plan.
- {:else if $user.subscription.type === "prepaid"}
-
- You already have a Prepaid subscription active. Your account
- balance is . Use
- the form below to top up your balance.
-
-
{:else}
- Pick one of the subscription plans above to upgrade your
- account to premium.
+ Pick a subscription plan to upgrade your account. Your
+ account balance is .
{/if}
+
{:else}
diff --git a/svelte/src/layout/checkout/Checkout.svelte b/svelte/src/layout/checkout/Checkout.svelte
index 800ae00..fbd6a3f 100644
--- a/svelte/src/layout/checkout/Checkout.svelte
+++ b/svelte/src/layout/checkout/Checkout.svelte
@@ -4,14 +4,38 @@ import PickAmount from "./PickAmount.svelte";
import PickCountry from "./PickCountry.svelte";
import { payment_providers, type CheckoutState } from "./CheckoutLib";
import PickName from "./PickName.svelte";
-import { get_misc_vat_rate, get_user } from "lib/NovaAPI";
+import { get_misc_subscriptions, get_misc_vat_rate, get_user, subscription_periods } from "lib/NovaAPI";
import { countries } from "country-data-list";
import PickProvider from "./PickProvider.svelte";
+import PickPlan from "./PickPlan.svelte";
-let status: CheckoutState = $state({country: null, provider: null, amount: 0, vat: 0, name: ""})
+let status: CheckoutState = $state({
+ subscription: undefined,
+ months: 1,
+ country: null,
+ provider: null,
+ amount: 0,
+ vat: 0,
+ name: "",
+})
onMount(async () => {
try {
+ // The plan links on the home page point here with the plan in the URL
+ const params = new URLSearchParams(window.location.search)
+ if (params.get("plan") !== null) {
+ const plan = (await get_misc_subscriptions())
+ .find(p => p.id === params.get("plan"))
+
+ if (plan !== undefined) {
+ const months = Number(params.get("months"))
+ status.subscription = plan
+ status.months = months >= plan.min_months &&
+ subscription_periods.some(p => p.months === months) ?
+ months : plan.min_months
+ }
+ }
+
const user = await get_user()
// Get the country from the user profile
@@ -39,9 +63,18 @@ onMount(async () => {
})
-
- {#if status.country !== null}
+
+ {#if status.subscription !== undefined}
+
+
{#if status.country !== null}
+{#if billed && included > 0 && used > included}
+
+ Data transfer above the {formatDataVolume(included, 3)} included in your
+ plan is billed separately, at
+ {formatEuros($user.subscription.price_per_tb_bandwidth, 2)} per TB.
+
+{/if}
+
{#if frac > 1}
- You have used all of your data cap. People can still download your
- files, but premium features are disabled. This means that the
- download page shows Nova branding, people who download your
- files have a daily download limit and hotlinking is disabled.
+ You have used all of your data transfer, so your files can't be
+ downloaded until your usage over the last 30 days drops below the
+ limit. Logged in users with data transfer left on their own plan
+ can still download them.
- You have used {(frac*100).toFixed(0)}% of your data cap. If your
- data runs out the premium features related to downloading will be
- disabled. This means that the download page shows Nova
- branding, people who download your files have a daily download limit
- and hotlinking is disabled.
+ You have used {(frac*100).toFixed(0)}% of your data transfer. When
+ it runs out your files can't be downloaded anymore.
- Please press "Allow" when asked if Nova can access your
- profile.
-
- {:else if patreon_result === "error"}
-
-
- An error occurred while linking Patreon subscription. Check if
- there are any Nova integrations under "Logged in with
- Patreon" on this page: https://www.patreon.com/settings/apps.
- Try disconnecting all Nova logins and try again.
-
-
- This can also happen if you canceled your Patreon membership
- before upgrading your Nova account. In that case, go to
- the memberships
- page and activate your membership again. If you already paid
- in the last 30 days you will not be charged again.
-
-
- It might also help to try linking the subscription in a
- different web browser. Sometimes cookies or cached redirects can
- cause issues with the authentication process. Try linking your
- subscription in Firefox for example.
-
-
- If the information above does not solve your issue then please
- contact me on Patreon or Discord. When
- contacting support please provide the following
- information: Server response: {patreon_message} Server
- error code: {patreon_error}
-
-
- {:else if patreon_result === "pledge_not_found"}
-
-
- We were not able to find your payment on Patreon. There can be a
- few reasons for this, which I will list below:
-
-
-
- Your payment is not confirmed yet. It can take up to 30
- minutes before your payment is confirmed. If you paid with
- PayPal it's usually instant, but with creditcard it can take
- longer. If your payment is not confirmed after 30 minutes
- please contact me on Patreon or Discord.
-
-
- You pledged without selecting a support tier. It happens
- sometimes that people pledge an amount of money without
- selecting a support tier. If you don't have a tier
- Nova won't know which subscription you should get,
- regardless of how much you paid. To fix this you can select
- a tier from the
- memberships page and proceed to checkout. If you already
- pledged the right amount you will not have to pay again.
-
-
- Or you have not pledged at all yet. In that case it's
- simple: go to
- Patreon and purchase a membership.
-
-
-
- When contacting support please provide the following
- information: Server response: {patreon_message} Server
- error code: {patreon_error}
-
-
- {:else if patreon_result === "success"}
-
- Success! Your Patreon pledge has been linked to your Nova
- account. You are now able to use Pro features.
-
- {/if}
-
-{/if}
-
-
diff --git a/svelte/src/user_home/Router.svelte b/svelte/src/user_home/Router.svelte
index d7d4bd8..df97938 100644
--- a/svelte/src/user_home/Router.svelte
+++ b/svelte/src/user_home/Router.svelte
@@ -2,16 +2,15 @@
import AccountSettings from "./AccountSettings.svelte";
import APIKeys from "./APIKeys.svelte";
import Transactions from "./Transactions.svelte";
-import Subscription from "./Subscription.svelte";
import ConnectApp from "./ConnectApp.svelte";
import ActivityLog from "./ActivityLog.svelte";
-import DepositCredit from "./DepositCredit.svelte";
import TabMenu, { type Tab } from "util/TabMenu.svelte";
import Dashboard from "./dashboard/Dashboard.svelte";
import AffiliatePrompt from "./AffiliatePrompt.svelte";
import { onMount } from "svelte";
import { get_user, type User } from "lib/NovaAPI";
import SharingSettings from "./SharingSettings.svelte";
+import SubscriptionManage from "./SubscriptionManage.svelte";
let pages: Tab[] = [
{
@@ -31,17 +30,12 @@ let pages: Tab[] = [
icon: "shopping_cart",
subpages: [
{
- path: "/user/subscription",
+ path: "/user/subscription/manage",
title: "Manage",
icon: "shopping_cart",
- component: Subscription,
+ component: SubscriptionManage,
}, {
- path: "/user/prepaid/deposit",
- title: "Deposit Credit",
- icon: "account_balance_wallet",
- component: DepositCredit,
- }, {
- path: "/user/prepaid/transactions",
+ path: "/user/subscription/transactions",
title: "Transactions",
icon: "receipt",
component: Transactions,
diff --git a/svelte/src/user_home/SharingSettings.svelte b/svelte/src/user_home/SharingSettings.svelte
index ca9bcdf..724ac3f 100644
--- a/svelte/src/user_home/SharingSettings.svelte
+++ b/svelte/src/user_home/SharingSettings.svelte
@@ -1,6 +1,6 @@
@@ -25,58 +27,40 @@ let frac = $derived(total <= 0 ? 0 : used / total)
-{#if !disable_warnings}
- {#if frac > 2.0}
-
-
- You are using more than 200% of your allowed storage space!
-
-
- We have started deleting your files to free up space. If you do not
- want to lose any more files please upgrade to a subscription which
- supports the volume of storage which you need.
-
- You have used all of your storage space. You won't be able to
- upload new files anymore. Please upgrade to a higher support
- tier to continue uploading files.
-
- Your files will not be deleted any sooner than normal at this
- moment. When your storage usage is over 200% we will start
- deleting your files to free up the space.
-
-
- {:else if frac > 0.8}
-
-
- You have used {(frac*100).toFixed(0)}% of your
- storage space. If your storage space runs out you won't be able
- to upload new files anymore. Please upgrade to a higher support
- tier to continue uploading files.
-
+ Storage above the limit of your plan is billed separately, at
+ {formatEuros($user.subscription.price_per_tb_storage, 2)} per TB per
+ month.
+
{/if}
+{:else if frac > 1}
+
+
+ You are storing more than your plan allows, so you can't upload new
+ files. Your existing files stay available. Delete some files or
+ upgrade your plan to continue uploading{#if billable}, or enable
+ usage based billing to pay for the
+ storage above the limit{/if}.
+
+ Thank you for supporting Nova! The credit has been added
+ to your account balance.
+
+
+ If your account credit has not been updated, please check the
+ status of your invoice on the invoices page.
+ Depending on the payment processor you used it can take a while
+ before your credit is deposited. SEPA transfers can take up to
+ two working days for example. When the deposit is complete you
+ will receive an e-mail. If it takes too long, contact
+ support@nova.storage.
+
+
+ {:else if window.location.hash === "#order_expired"}
+
+
Order expired
+
+ This order has expired. Please create a new order below.
+
+
+ {:else if window.location.hash === "#order_canceled"}
+
+
Order canceled
+
+ You have chosen to cancel the order. If you still want to
+ proceed with the order you can initiate payment again below. If
+ not then you can let the invoice expire.
+
+
+ {/if}
+
+
Manage subscription
+
+ Subscriptions are charged from your account credit every day. Storage and
+ traffic above the amounts included in your plan are charged at €4 per TB
+ per month for storage and €1 per TB for data transfer. Your current
+ account balance is
+
+
+ {#if unpaid_invoice > 1}
+
+
+ You still have an unpaid invoice open. Please pay that one
+ before requesting a new invoice. You can find unpaid
+ invoices at the bottom of this page. You can cancel an
+ invoice by clicking Pay, and then clicking the Back link at
+ the bottom of the page.
+
+
+ {:else}
+
+ {/if}
+
+
Past invoices
+
+ Invoices are deleted after one year of inactivity.
+
+
+
+
+
+
ID
+
Created
+
Amount
+
VAT
+
Country
+
Method
+
Status
+
+
+
+
+ {#each invoices as row (row.id)}
+
+
{row.id}
+
{formatDate(row.time, true, true, false)}
+
+
+
{row.country}
+
{row.payment_method}
+
+ {#if row.status === "open"}
+ Waiting for payment
+ {:else if row.status === "processing"}
+ Payment received, waiting for confirmations
+ {:else if row.status === "paid"}
+ Paid
+ {:else if row.status === "expired"}
+ Expired
+ {:else if row.status === "canceled"}
+ Canceled
+ {:else}
+ {row.status}
+ {/if}
+
- {#if $user.subscription.monthly_transfer_cap > 0}
- Data transfer limit: {formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
- {:else}
+ {#if $user.subscription.monthly_transfer_cap <= 0}
No data transfer limit
+ {:else if $user.subscription.price_per_tb_bandwidth > 0}
+ Data transfer included: {formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
+ per month ({formatDataVolume($user.monthly_transfer_used, 3)} used)
+ {:else}
+ Data transfer limit: {formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
{/if}
@@ -58,6 +59,9 @@ $effect(() => {
border-radius: 6px;
transition-property: width;
}
+.red {
+ background: var(--danger_color);
+}
.no_animation {
transition-property: none;
}
diff --git a/svelte/src/wrap/MainMenu.svelte b/svelte/src/wrap/MainMenu.svelte
index 0d42fdb..304da75 100644
--- a/svelte/src/wrap/MainMenu.svelte
+++ b/svelte/src/wrap/MainMenu.svelte
@@ -18,6 +18,7 @@ import Tree from "./Tree.svelte";
import MenuEntry from "./MenuEntry.svelte";
import { writable } from "svelte/store";
import { breadcrumbs_store } from "./BreadcrumbStore";
+import ProgressBar from "util/ProgressBar.svelte";
// The menu swipe will be detected if it was less than this much pixels from the
// screen edge
@@ -176,18 +177,37 @@ const set_offset = (off: number) => {
{#snippet body()}
{/snippet}
@@ -305,7 +325,7 @@ const set_offset = (off: number) => {
.nav {
display: flex;
flex-direction: column;
- width: 15em;
+ width: 16em;
margin: 0;
padding: 4px;
backdrop-filter: blur(3px);
diff --git a/svelte/src/wrap/Router.svelte b/svelte/src/wrap/Router.svelte
index d74d9c7..89022e0 100644
--- a/svelte/src/wrap/Router.svelte
+++ b/svelte/src/wrap/Router.svelte
@@ -57,42 +57,39 @@ let pages: Tab[] = [
]
onMount(async () => {
- await load_page(window.location.pathname, false)
+ await load_page(window.location.href, false)
})
let current_page: Tab = $state(null)
-const load_page = async (pathname: string, history: boolean): Promise => {
+// find_page resolves a URL path to the page which renders it. It returns null
+// when no page matches, in which case the browser has to handle the navigation
+const find_page = (pathname: string): Tab => {
+ const path = decodeURIComponent(pathname)
+ let by_prefix: Tab = null
+ for (const page of pages) {
+ if (path === page.path) {
+ return page
+ }
+ if (page.prefix !== undefined && path.startsWith(page.prefix)) {
+ by_prefix = page
+ }
+ }
+ return by_prefix
+}
+
+// href can be a full URL or a path, and may contain a query string and hash.
+// Those are not used for routing, but they do need to survive the navigation
+const load_page = async (href: string, history: boolean): Promise => {
+ const url = new URL(href, window.location.href)
console.debug(
- "Page router: Navigating to page", pathname,
+ "Page router: Navigating to page", url.pathname,
"current path", window.location.pathname,
"log history:", history,
)
- let user: User
- try {
- user = await get_user()
- } catch (err) {
- alert("Failed to get user details: " + err)
- }
-
- const path_decoded = decodeURIComponent(pathname)
- let page_by_path: Tab = null
- let page_by_prefix: Tab = null
- for (const page of pages) {
- if (path_decoded === page.path) {
- page_by_path = page
- }
- if (page.prefix !== undefined && path_decoded.startsWith(page.prefix)) {
- page_by_prefix = page
- }
- }
-
- if (page_by_path !== null) {
- current_page = page_by_path
- } else if (page_by_prefix !== null) {
- current_page = page_by_prefix
- } else {
+ const page = find_page(url.pathname)
+ if (page === null) {
current_page = {
path: "",
title: "Not Found",
@@ -101,19 +98,37 @@ const load_page = async (pathname: string, history: boolean): Promise =
return false
}
+ let user: User
+ try {
+ user = await get_user()
+ } catch (err) {
+ alert("Failed to get user details: " + err)
+ }
+
// If this page requires login, and the user is not logged in, then we
- // redirect the user to the login page
- if (current_page.login === true && (user === null || user === undefined)) {
+ // redirect the user to the login page. The page is deliberately not mounted
+ // first, it would fire authenticated requests which are all bound to fail
+ if (page.login === true && (user === null || user === undefined)) {
console.debug("User is not logged in, redirecting to login page", user)
- return load_page("/login", true)
+ return load_page(
+ "/login?redirect=" + encodeURIComponent(url.pathname + url.search),
+ true,
+ )
}
- window.document.title = current_page.title+" / Nova"
+ window.document.title = page.title+" / Nova"
+ // The address bar has to be updated before the page is mounted. Pages read
+ // their parameters from it when they initialize, the login page picks up
+ // the redirect target this way
if (history) {
- window.history.pushState({}, window.document.title, pathname)
+ window.history.pushState(
+ {}, window.document.title, url.pathname + url.search + url.hash,
+ )
}
+ current_page = page
+
if (current_page.custom_breadcrumbs !== true) {
breadcrumbs_store.set(breadcrumbs)
}
@@ -129,29 +144,53 @@ const load_page = async (pathname: string, history: boolean): Promise =
return true
}
-const click = async (e: MouseEvent) => {
- const origin = (e.target as Element).closest("a");
+const click = (e: MouseEvent) => {
+ // Middle clicks, right clicks and modified clicks open the link in a new
+ // tab or window. That's the browser's job, not ours
+ if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
+ return
+ }
+ const origin = (e.target as Element).closest("a")
if (origin === null) {
return
}
- const url = URL.parse(origin.href)
- if (window.location.host !== url.host) {
+ // Links which explicitly ask for browser behaviour: a new tab, a download,
+ // or an external destination
+ if (origin.target !== "" || origin.hasAttribute("download") ||
+ origin.rel === "external") {
return
}
- console.log("Caught link click to", url.pathname);
-
- // Try to load the page, if the page was found we cancel the browser
- // navigation event so we can handle it ourselves
- if (await load_page(url.pathname, true) === true) {
- e.preventDefault()
+ // Other hosts, and protocols like mailto: and magnet:, have no host match
+ const url = URL.parse(origin.href)
+ if (url === null || url.host !== window.location.host) {
+ return
}
+
+ // In-page anchors are left to the browser, it knows how to scroll to them
+ if (url.pathname === window.location.pathname && url.hash !== "") {
+ return
+ }
+
+ // Paths without a page of their own are served by the backend, like the API
+ // and file downloads. Loading them here would replace the page the user is
+ // looking at with a Not Found error while the browser navigates away
+ if (find_page(url.pathname) === null) {
+ return
+ }
+
+ console.log("Caught link click to", url.pathname)
+
+ // The event has to be cancelled before the first await, after that the
+ // browser has already committed to the navigation
+ e.preventDefault()
+ load_page(origin.href, true)
}
const popstate = (e: PopStateEvent) => {
- load_page(window.location.pathname, false)
+ load_page(window.location.href, false)
}