diff --git a/svelte/src/filesystem/Menu.svelte b/svelte/src/filesystem/Menu.svelte index ed43cf1..3d42d60 100644 --- a/svelte/src/filesystem/Menu.svelte +++ b/svelte/src/filesystem/Menu.svelte @@ -76,7 +76,7 @@ const open = () => dialog.open(button.getBoundingClientRect()) + {#if status.country !== null} - {/each} +
+ {#if covered} -
-
Custom amount €
- -
+ Subscription {status.subscription.name} costs + + for {status.months} month{status.months === 1 ? "" : "s"}, + which your account credit of already + covers. You don't have to pay anything now. -
- Total including VAT: - -
+ {:else if status.subscription !== null} + + Subscription {status.subscription.name} costs + + for {status.months} month{status.months === 1 ? "" : "s"}. + + {#if credit > 0} + Your account credit of is subtracted + from that, so you need to deposit at least + to activate the plan. + {/if} + + You can deposit more, the remainder will extend the runtime of your + subscription plan. + + {:else} + + Please choose an amount + + {/if} +
- -
-

- This Nova premium plan costs €1 per month, but due to - processing fees we can't accept payments less than €10. So your - deposit will give roughly 10 months of premium service depending on - usage. You can track your spending on the transactions page. + +

+ {#if covered} + + + + {:else} + +
+ {#each amounts as a} + + {/each} + +
+
Custom amount €
+ +
+ +
+ VAT: + , + total: + +
+ + +
+ + {/if} +
+ +{#if runs_until !== null} +
+
+ {#if extending} + + This credit deposit will extend your current subscription until + about {runs_until}, assuming usage within + the subscription plan's limits. + + {:else} + + This credit deposit will keep the {status.subscription.name} plan + running until about {runs_until}, assuming + usage within the plan's limits. + + {/if} +
+{/if} + +
+ +{#if status.subscription !== null} +
Save money by paying ahead
+ + + + + + + + + + + + {#each periods as p (p.months)} + + + + + + + + {/each} + +
Billing periodPricePer dayYou save
{p.name} + + + + + + ({Math.round((1-p.discount)*100)}%) + + {#if p.months === status.months} + check Selected + {:else} + + {/if} +
+{/if} + +

+ Subscriptions are charged from your account credit every day. Due to + processing fees we can't accept payments less than €10. You can track your + spending on the transactions page.

diff --git a/svelte/src/layout/checkout/SubscriptionPlans.svelte b/svelte/src/layout/checkout/SubscriptionPlans.svelte new file mode 100644 index 0000000..8636051 --- /dev/null +++ b/svelte/src/layout/checkout/SubscriptionPlans.svelte @@ -0,0 +1,124 @@ + + +{#snippet price(plan: Subscription, period: typeof subscription_periods[0])} +
{period.label}
+
+ + / {period.months} month{period.months === 1 ? "" : "s"} +
+{/snippet} + +
+
+ {#each plans as plan (plan.id)} +
+
+ {plan.name} +
+ / month +
+
+
+ {formatDataVolume(plan.storage_limit, 0)} + storage space +
+
+ {formatDataVolume(plan.monthly_transfer_cap, 0)} + egress traffic per month +
+
+
+ {#each subscription_periods as period (period.months)} + {#if period.months < plan.min_months} + {period.name} plan not available + {:else if onpick !== undefined} + + {:else} + + {@render price(plan, period)} + + {/if} + {/each} +
+
+ {/each} +
+
+ + diff --git a/svelte/src/lib/NovaAPI.ts b/svelte/src/lib/NovaAPI.ts index 7ca53b0..c4a0fb5 100644 --- a/svelte/src/lib/NovaAPI.ts +++ b/svelte/src/lib/NovaAPI.ts @@ -21,6 +21,8 @@ export type User = { filesystem_node_count: number, is_admin: boolean, balance_micro_eur: number, + subscription_months: number, + subscription_period_end: string, monthly_transfer_cap: number, monthly_transfer_used: number, file_viewer_branding: Map, @@ -29,12 +31,15 @@ export type User = { checkout_country: string, checkout_name: string, checkout_provider: string, + usage_based_billing: boolean, } export type Subscription = { id: string, name: string, type: string, + monthly_fee: number, + min_months: number, file_size_limit: number, storage_limit: number, price_per_tb_storage: number, @@ -156,13 +161,10 @@ export const put_user = async (data: Object) => { { method: "PUT", body: dict_to_form(data) }, )) - // Update the user store - user.update((user: User) => { - for (let key of Object.keys(data)) { - user[key] = data[key] - } - return user - }) + // Fetch the user again to update the store. The submitted fields don't + // always match the fields on the user object, and the server can change or + // ignore them, so we can't apply the submitted values locally + await request_user() } export const logout_user = async (redirect_path: string) => { @@ -183,3 +185,49 @@ export type VATRate = { export const get_misc_vat_rate = async (country_code: string) => { return await check_response(await fetch(get_endpoint() + "/misc/vat_rate/" + country_code)) as VATRate } + +// The available subscription plans. The discount for a longer subscription +// period is applied by the server too, see SubscriptionDiscount in Go +export type SubscriptionPeriod = { + months: number, + discount: number, + name: string, + label: string, +} + +export const subscription_periods: SubscriptionPeriod[] = [ + { months: 1, discount: 1, name: "Monthly", label: "Monthly" }, + { months: 6, discount: 0.9, name: "Bi-yearly", label: "Bi-yearly (save 10%)" }, + { months: 12, discount: 0.8, name: "Yearly", label: "Yearly (save 20%)" }, +] + +// Subscriptions are charged per day. This is the average length of a month, the +// same value the server divides the monthly fee by +export const days_per_month = 365.25 / 12 + +export const get_misc_subscriptions = async () => { + return await check_response( + await fetch(get_endpoint() + "/misc/subscriptions") + ) as Subscription[] +} + +export type Invoice = { + id: string, + time: Date, + amount: number, + vat: number, + country: string, + payment_gateway: string, + payment_method: string, + status: "open" | "processing" | "paid" | "expired" | "canceled", +} + +export const get_user_invoices = async (): Promise => { + const invoices = await check_response( + await fetch(get_endpoint() + "/user/invoice"), + ) as Invoice[] + + invoices.forEach(row => row.time = new Date(row.time)) + invoices.sort((a, b) => b.time.getTime() - a.time.getTime()) + return invoices +} diff --git a/svelte/src/login/Router.svelte b/svelte/src/login/Router.svelte index b9037e4..bebe75f 100644 --- a/svelte/src/login/Router.svelte +++ b/svelte/src/login/Router.svelte @@ -25,16 +25,16 @@ onMount(async () => { if ( params.get("link_login_user_id") === null && params.get("link_login_id") === null && - user.username !== "" + user !== null && user.username !== "" ) { console.debug("User is already logged in, redirecting to user dashboard", user) let login_redirect = params.get("redirect") if (typeof login_redirect === "string" && login_redirect.startsWith("/")) { console.debug("redirecting user to requested path", login_redirect) - window.location.pathname = login_redirect + window.location.href = login_redirect } else { - window.location.pathname = "/user" + window.location.href = "/user" } } }) diff --git a/svelte/src/user_home/ConnectApp.svelte b/svelte/src/user_home/ConnectApp.svelte index 9d56654..8b3023a 100644 --- a/svelte/src/user_home/ConnectApp.svelte +++ b/svelte/src/user_home/ConnectApp.svelte @@ -3,7 +3,6 @@ import { onMount } from "svelte"; import CopyButton from "layout/CopyButton.svelte"; import { loading_finish, loading_start } from "lib/Loading"; -let app_name = $state("") let api_key = $state("") const create_key = async () => { loading_start() @@ -39,11 +38,13 @@ const toggle_show_key = () => { } } -onMount(() => { +let app_name = $derived(() => { let app = new URL(window.location.href).searchParams.get("app") if (app) { - app_name = app + consoke.log(app) + return app } + return null }) diff --git a/svelte/src/user_home/DepositCredit.svelte b/svelte/src/user_home/DepositCredit.svelte deleted file mode 100644 index e347abd..0000000 --- a/svelte/src/user_home/DepositCredit.svelte +++ /dev/null @@ -1,125 +0,0 @@ - - -
-

Deposit credit

-

- Nova credit can be used for our Prepaid subscription plan, which - is different from the Patreon plans. Instead of monthly limits, with - Prepaid there are no limits. You pay for what you use, at a rate of €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. -

-
- - - - - - - - - - - - - - - {#each invoices as row (row.id)} - - - - - - - - - - - {/each} - -
IDCreatedAmountVATCountryMethodStatus
{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 row.status === "open"} - - paid Pay - - {/if} -
-
-
diff --git a/svelte/src/user_home/HotlinkProgressBar.svelte b/svelte/src/user_home/HotlinkProgressBar.svelte index 062e573..e4348b5 100644 --- a/svelte/src/user_home/HotlinkProgressBar.svelte +++ b/svelte/src/user_home/HotlinkProgressBar.svelte @@ -1,6 +1,6 @@ @@ -25,48 +29,54 @@ let frac = $derived(used / total) +{#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.

- - {#if $user !== null && $user.monthly_transfer_cap > 0} + {#if bill_shock}

- You have a billshock limit configured. increase or disable the limit to - continue sharing files. + This is your bill shock limit. Increase + or disable the limit to continue sharing files.

+ {:else} +

+ Upgrade your plan{#if billable} or enable + usage based billing{/if} to continue + sharing files. +

+ + bolt Upgrade options + {/if} - - - bolt Upgrade options -
{:else if frac > 0.8}

- 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.

- - {#if $user !== null && $user.monthly_transfer_cap > 0} + {#if bill_shock}

- You have a billshock limit configured. increase or disable the limit to - continue sharing files. + This is your bill shock limit, you can change it in the + sharing settings.

+ {:else} + + bolt Upgrade options + {/if} - - - bolt Upgrade options -
{/if} diff --git a/svelte/src/user_home/PatreonActivationResult.svelte b/svelte/src/user_home/PatreonActivationResult.svelte deleted file mode 100644 index 0c959cf..0000000 --- a/svelte/src/user_home/PatreonActivationResult.svelte +++ /dev/null @@ -1,124 +0,0 @@ - - - -{#if patreon_result !== ""} - {#if patreon_error === "patreon_authentication_denied"} -
- 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: -

    -
      -
    1. - 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. -
    2. -
    3. - 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. -
    4. -
    5. - Or you have not pledged at all yet. In that case it's - simple: go to - Patreon and purchase a membership. -
    6. -
    -

    - 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. -

    - - bolt Upgrade options - -
    - {:else if frac > 1.0} -
    -

    - 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. -

    - - bolt Upgrade options - -

    - 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. -

    - - bolt Upgrade options - -
    +{#if billed} + {#if frac > 1} +

    + 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}. +

    + + bolt Upgrade options + +
    +{:else if frac > 0.8} +
    +

    + You have used {(frac*100).toFixed(0)}% of your storage space. When + it runs out you won't be able to upload new files anymore. +

    + + bolt Upgrade options + +
    {/if} diff --git a/svelte/src/user_home/SubscriptionManage.svelte b/svelte/src/user_home/SubscriptionManage.svelte new file mode 100644 index 0000000..1d9a451 --- /dev/null +++ b/svelte/src/user_home/SubscriptionManage.svelte @@ -0,0 +1,144 @@ + + +
    + {#if window.location.hash === "#checkout_complete"} +
    +

    Payment successful!

    +

    + 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. +

    +
    + + + + + + + + + + + + + + + {#each invoices as row (row.id)} + + + + + + + + + + + {/each} + +
    IDCreatedAmountVATCountryMethodStatus
    {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 row.status === "open"} + + paid Pay + + {/if} + + description PDF + +
    +
    +
    diff --git a/svelte/src/user_home/dashboard/CardSubscription.svelte b/svelte/src/user_home/dashboard/CardSubscription.svelte index 66c3171..3d4d510 100644 --- a/svelte/src/user_home/dashboard/CardSubscription.svelte +++ b/svelte/src/user_home/dashboard/CardSubscription.svelte @@ -9,9 +9,7 @@ import { formatDataVolume } from "util/Formatting";
  • Supporter level: {$user.subscription.name}
    shopping_cart - Manage subscriptions
    - add_link - Link Patreon subscription + Manage subscriptions
  • {#if $user.balance_micro_eur !== 0} @@ -19,10 +17,10 @@ import { formatDataVolume } from "util/Formatting"; Current account balance:
    account_balance_wallet - Deposit credit
    + Deposit credit
    receipt - Transaction log + Transaction log {#if $user.balance_micro_eur > 0 && $user.subscription.id === ""}
    @@ -36,19 +34,31 @@ import { formatDataVolume } from "util/Formatting"; Max file size: {formatDataVolume($user.subscription.file_size_limit, 3)}
  • - {#if $user.subscription.storage_limit > 0} - Storage limit: {formatDataVolume($user.subscription.storage_limit, 3)} - {:else} + {#if $user.subscription.storage_limit <= 0} No storage limit + {:else if $user.subscription.price_per_tb_storage > 0} + Storage included: {formatDataVolume($user.subscription.storage_limit, 3)} + ({formatDataVolume($user.storage_space_used, 3)} used) + {:else} + Storage limit: {formatDataVolume($user.subscription.storage_limit, 3)} {/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}
  • + {#if $user.subscription_period_end} +
  • + Subscription period: {$user.subscription_months} month{$user.subscription_months === 1 ? "" : "s"}, + renews on {new Date($user.subscription_period_end).toLocaleDateString()} +
  • + {/if}
  • {#if $user.subscription.file_sharing === true} File sharing enabled diff --git a/svelte/src/user_home/dashboard/CardUsage.svelte b/svelte/src/user_home/dashboard/CardUsage.svelte index 3aafa58..fef5966 100644 --- a/svelte/src/user_home/dashboard/CardUsage.svelte +++ b/svelte/src/user_home/dashboard/CardUsage.svelte @@ -34,13 +34,14 @@ let user: User = $state() onMount(async () => { user = await get_user() - if (user.monthly_transfer_cap > 0) { - transfer_cap = user.monthly_transfer_cap - } else if (user.subscription.monthly_transfer_cap > 0) { - transfer_cap = user.subscription.monthly_transfer_cap - } else { - transfer_cap = -1 + // The lowest limit which blocks downloads. With usage based billing the + // plan's limit is billed instead, only the bill shock limit blocks + let caps = [user.monthly_transfer_cap] + if (!user.usage_based_billing || user.subscription.price_per_tb_bandwidth <= 0) { + caps.push(user.subscription.monthly_transfer_cap) } + caps = caps.filter(cap => cap > 0) + transfer_cap = caps.length > 0 ? Math.min(...caps) : -1 storage_limit = user.subscription.storage_limit diff --git a/svelte/src/util/ProgressBar.svelte b/svelte/src/util/ProgressBar.svelte index 7bef300..f41b8a1 100644 --- a/svelte/src/util/ProgressBar.svelte +++ b/svelte/src/util/ProgressBar.svelte @@ -17,19 +17,19 @@ let { style?: string; } = $props(); -let percent = $state(0) +let percent = $derived.by(() => { + // Don't allow more than 100% progress + if (used / total > 1) { + return 100 + } else { + return (used / total) * 100 + } +}) $effect(() => { // Avoid division by 0 if (total === 0) { total = 1 } - - // Don't allow more than 100% progress - if (used / total > 1) { - percent = 100 - } else { - percent = (used / total) * 100 - } }); @@ -37,6 +37,7 @@ $effect(() => {
    90} style="width: {percent}%; transition-timing-function: {animation}; transition-duration: {speed}ms;">
    @@ -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()}
    -
    Subscription
    -
    {$user.subscription.name}
    +
    Sub
    +
    + {$user.subscription.name} + {#if $user.subscription_months === 1} + (monthly) + {:else if $user.subscription_months === 6} + (bi-yearly) + {:else if $user.subscription_months === 12} + (yearly) + {/if} +
    - {#if $user.subscription.type === "prepaid"} -
    Credit
    -
    - {/if} +
    Credit
    +
    -
    Storage used
    -
    {formatDataVolume($user.filesystem_storage_used, 3)}
    -
    Transfer used
    -
    {formatDataVolume($user.monthly_transfer_used, 3)}
    +
    Storage
    +
    + {formatDataVolume($user.filesystem_storage_used, 3)} / + {formatDataVolume($user.subscription.storage_limit, 3)} +
    +
    + +
    +
    Transfer
    +
    + {formatDataVolume($user.monthly_transfer_used, 3)} / + {formatDataVolume($user.subscription.monthly_transfer_cap, 3)} +
    +
    + +
    {/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) }