Graphical overhaul.

Remove all legacy pixeldrain pages, handlers and assets.
Remove old theming engine.
Make logo responsive to the accent colour.
Lots of other stuff.
This commit is contained in:
2026-10-01 02:06:00 +02:00
parent 7b2e4b4240
commit 58db683fc8
98 changed files with 1025 additions and 6475 deletions

View File

@@ -124,8 +124,8 @@ onMount(() => {
data: [],
borderWidth: 2,
pointRadius: 0,
borderColor: color_by_name("highlight_color"),
backgroundColor: color_by_name("highlight_color"),
borderColor: color_by_name("accent_color"),
backgroundColor: color_by_name("accent_color"),
},
];
graphDownloads.data().datasets = [
@@ -134,8 +134,8 @@ onMount(() => {
data: [],
borderWidth: 2,
pointRadius: 0,
borderColor: color_by_name("highlight_color"),
backgroundColor: color_by_name("highlight_color"),
borderColor: color_by_name("accent_color"),
backgroundColor: color_by_name("accent_color"),
},
];

View File

@@ -1,10 +1,9 @@
<script lang="ts">
import Chart from "util/Chart.svelte";
import type Chart from "util/Chart.svelte";
import { formatDataVolume, formatDate, formatThousands } from "util/Formatting";
import Modal from "util/Modal.svelte";
import { fs_path_url, fs_share_hotlink_url, fs_share_url, fs_timeseries, type FSNode } from "lib/FilesystemAPI.svelte";
import { color_by_name } from "util/Util";
import { tick } from "svelte";
import CopyButton from "layout/CopyButton.svelte";
import type { FSNavigator } from "./FSNavigator";
@@ -43,12 +42,9 @@ let total_downloads = $state(0)
let total_egress = $state(0)
let update_chart = async (base: FSNode, timespan: number, interval: number) => {
if (chart === undefined) {
// Wait for the chart element to render, if it's not rendered already
await tick()
}
if (!visible || base.type !== "file" || chart === undefined) {
// The chart is not there until it's downloaded. The $effect above reads it
// through this function, so it calls this function again when it's ready
if (!visible || base.type !== "file" || !chart) {
return
}
@@ -116,15 +112,15 @@ let update_chart = async (base: FSNode, timespan: number, interval: number) => {
label: "Downloads",
borderWidth: 2,
pointRadius: 0,
borderColor: color_by_name("highlight_color"),
backgroundColor: color_by_name("highlight_color"),
borderColor: color_by_name("accent_color"),
backgroundColor: color_by_name("accent_color"),
data: resp.downloads.amounts,
}, {
label: "Egress",
borderWidth: 2,
pointRadius: 0,
borderColor: color_by_name("danger_color"),
backgroundColor: color_by_name("danger_color"),
borderColor: color_by_name("red_color"),
backgroundColor: color_by_name("red_color"),
yAxisID: "y1",
data: resp.egress.amounts,
},
@@ -244,7 +240,10 @@ let direct_share_url = $derived(fs_share_hotlink_url($nav.path))
{/each}
</div>
<Chart bind:this={chart} />
<!-- Chart.js is big, so it's only downloaded when it's needed -->
{#await import("util/Chart.svelte") then c}
<c.default bind:this={chart} />
{/await}
{/if}
</Modal>

View File

@@ -1,5 +1,5 @@
import { loading_finish, loading_start } from "lib/Loading";
import { fs_get_node, fs_encode_path, fs_split_path } from "lib/FilesystemAPI.svelte";
import { fs_get_node, fs_encode_path, fs_split_path, fs_path_from_json } from "lib/FilesystemAPI.svelte";
import type { FSNode, FSPath, FSPermissions, FSContext } from "lib/FilesystemAPI.svelte";
export class FSNavigator {
@@ -24,16 +24,6 @@ export class FSNavigator {
this.history_enabled = history_enabled
}
// The popstate event can be used to listen for navigation events. Register
// this event listener on the <svelte:window> in the parent element. When
// the user presses the back or forward buttons in the browser we'll catch
// the event and navigate to the proper directory
popstate = (e: PopStateEvent) => {
// Get the part of the URL after the fs root and navigate to it
const path = window.location.pathname.replace(/^\/d/, "")
this.navigate(decodeURI(path), false)
}
// The FSNavigator acts as a svelte store. This allows for DOM reactivity.
// This works by implementing the store contract:
// https://svelte.dev/docs/svelte-components#script-4-prefix-stores-with-$-to-access-their-values
@@ -70,7 +60,13 @@ export class FSNavigator {
try {
loading_start()
const resp = await fs_get_node(path)
// The server embeds the node which the page was opened on. It's only
// valid for the first navigation, which is to that same page
const resp = window.initial_node !== undefined
? fs_path_from_json(window.initial_node)
: await fs_get_node(path)
window.initial_node = undefined
this.open_node(resp, push_history)
} catch (err: any) {
if (err.value !== undefined && err.value === "path_not_found") {

View File

@@ -8,6 +8,7 @@ import FilePreview from "./viewers/FilePreview.svelte";
import FSUploadWidget from "./upload_widget/FSUploadWidget.svelte";
import { global_navigator } from "./FSNavigator"
import { css_from_path } from "filesystem/edit_window/Branding";
import { update_favicon } from "util/NovaLogo.svelte";
import AffiliatePrompt from "user_home/AffiliatePrompt.svelte";
import { current_page_store } from "wrap/RouterStore";
import SearchBar from "./SearchBar.svelte";
@@ -46,11 +47,13 @@ onMount(() => {
// styles take precedence since they're lower level, and we can clean it
// up afterwards without overwriting global style
document.body.style = css_from_path(nav.path)
update_favicon()
})
return () => {
page_sub()
nav_sub()
document.body.style = ""
update_favicon()
}
})

View File

@@ -1,132 +0,0 @@
<script lang="ts">
import NovaLogo from "util/NovaLogo.svelte";
import Button from "layout/Button.svelte";
import Euro from "util/Euro.svelte";
import { formatDataVolume } from "util/Formatting";
import { user } from "lib/UserStore";
import Dialog from "layout/Dialog.svelte";
let button: HTMLButtonElement = $state()
let dialog: Dialog = $state()
let {
no_login_label = "Nova",
hide_name = true,
hide_logo = false,
style = "",
embedded = false
}: {
no_login_label?: string;
// Hide the label if the screen is smaller than 800px
hide_name?: boolean;
hide_logo?: boolean;
style?: string;
embedded?: boolean;
} = $props();
let target = $derived(embedded ? "_blank" : "_self")
const open = () => dialog.open(button.getBoundingClientRect())
</script>
<div class="wrapper">
<button bind:this={button} onclick={open} class="button round" title="Menu" style={style}>
{#if !hide_logo}
<NovaLogo style="height: 1.6em; width: 1.6em;"/>
{/if}
<span class="button_username" class:hide_name>
{#if $user !== null}
{$user.username === "" ? no_login_label : $user.username}
{/if}
</span>
</button>
</div>
<Dialog bind:this={dialog}>
<div class="menu">
{#if $user !== null}
<Button link_href="/user" link_target={target} icon="dashboard" label="Dashboard" />
<div class="separator"></div>
<div class="stats_table">
<div>Subscription</div>
<div>{$user.subscription.name}</div>
{#if $user.subscription.type === "prepaid"}
<div>Credit</div>
<div><Euro amount={$user.balance_micro_eur}/></div>
{/if}
<div>Storage used</div>
<div>{formatDataVolume($user.filesystem_storage_used, 3)}</div>
<div>Transfer used</div>
<div>{formatDataVolume($user.monthly_transfer_used, 3)}</div>
</div>
<div class="separator"></div>
<Button link_href="/d/me" link_target={target} icon="folder" label="My Filesystem"/>
<Button link_href="/filesystem" link_target={target} icon="description" label="Filesystem Guide"/>
<div class="separator"></div>
<Button link_href="/user/filemanager#files" link_target={target} icon="image" label="My Files"/>
<Button link_href="/user/filemanager#lists" link_target={target} icon="photo_library" label="My Albums"/>
<div class="separator"></div>
<Button link_href="/user/settings" link_target={target} icon="settings" label="Account Settings"/>
<Button link_href="/user/subscription" link_target={target} icon="shopping_cart" label="Subscription"/>
<Button link_href="/user/subscription/transactions" link_target={target} icon="receipt" label="Transactions"/>
{#if $user.is_admin}
<div class="separator"></div>
<Button link_href="/admin" link_target={target} icon="admin_panel_settings" label="Admin Panel"/>
{/if}
{:else}
<Button link_href="/" link_target={target} icon="home" label="Home"/>
<Button link_href="/#pro" link_target={target} icon="star" label="Get Premium"/>
<Button link_href="/login" link_target={target} icon="person" label="Log in"/>
<Button link_href="/register" link_target={target} icon="person" label="Register"/>
{/if}
</div>
</Dialog>
<style>
.wrapper {
flex-grow: 0;
flex-shrink: 0;
display: inline-flex;
align-self: center;
}
.button {
flex: 0 0 content;
}
.button_username {
margin: 0 4px;
}
.menu {
display: flex;
flex-direction: column;
max-width: 15em;
}
.separator {
height: 1px;
margin: 2px 0;
width: 100%;
background-color: var(--separator);
}
.stats_table {
display: grid;
grid-template-columns: auto auto;
gap: 0.2em 1em;
margin: 3px;
}
/* Hide username on small screen */
@media(max-width: 800px) {
.hide_name {
display: none;
}
}
</style>

View File

@@ -287,12 +287,12 @@ const window_keydown = (e: KeyboardEvent) => {
align-items: center;
gap: 4px;
text-decoration: none;
color: var(--text-color);
color: var(--body_text_color);
padding: 2px;
}
.node_selected {
background: var(--highlight_background);
color: var(--highlight_text_color);
background: var(--accent_background);
color: var(--accent_text_color);
}
.node:hover:not(.node_selected) {
background: var(--input_background);

View File

@@ -42,7 +42,8 @@ const add_password = (e: SubmitEvent) => {
}
const generate_password = () => {
// 32 hexadecimal characters, which any S3 client accepts as secret key
new_password = crypto.randomUUID().replaceAll("-", "")
const bytes = crypto.getRandomValues(new Uint8Array(16))
new_password = Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("")
}
// Passwords stay hidden until their reveal button is pressed
let revealed: {[password: string]: boolean} = $state({})

View File

@@ -11,12 +11,11 @@ type Style = {
input_background?: string,
input_hover_background?: string,
input_text?: string,
highlight_color?: string,
highlight_background?: string,
highlight_text_color?: string,
accent_color?: string,
accent_shade?: string,
accent_background?: string,
accent_text_color?: string,
link_color?: string,
danger_color?: string,
danger_text_color?: string,
background_color?: string,
background?: string,
background_text_color?: string,
@@ -32,8 +31,45 @@ type Style = {
background_image_size?: string,
background_image_position?: string,
background_image_repeat?: string,
color_scheme?: string,
green_color?: string,
red_color?: string,
yellow_color?: string,
blue_color?: string,
green_tint?: string,
red_tint?: string,
yellow_tint?: string,
blue_tint?: string,
logo_color_1?: string,
logo_color_2?: string,
logo_color_3?: string,
logo_color_4?: string,
}
// The hues of the accents for success, errors, warnings and notices, and the
// accents for light themes. The dark defaults in layout.css are derived from
// the default accent colour, the light defaults are these light accents.
// Branding can also set each accent by itself
const accent_hues = { green: 140, red: 8, yellow: 45, blue: 215 }
const light_accents = { green: "#15793a", red: "#ba2c37", yellow: "#995c00", blue: "#2164ca" }
const default_accent = "#49dfa0"
// The colourways of the logo, as designed. The hue the colourway is closest to,
// the colours of the bars and the colours of the diagonal
const logo_colourways: [number, string, string, string, string][] = [
[1, "#f62e27", "#bb1d1c", "#bb1c1c", "#ee2c26"], // Red
[20, "#f78146", "#bb5420", "#ee7a40", "#c05823"], // Orange
[54, "#ffe500", "#8c8015", "#ead204", "#6d6310"], // Yellow
[71, "#dde741", "#74a511", "#8bb925", "#eaed38"], // Lime
[120, "#15c615", "#0c750c", "#0c730c", "#0c730c"], // Green
[141, "#45ed52", "#11a574", "#24b880", "#39ef3d"], // Mint
[190, "#48eef1", "#1378a8", "#258eba", "#38e8ec"], // Light blue
[230, "#4b82f5", "#1111a5", "#2354b5", "#393bee"], // Dark blue
[253, "#974af2", "#212eae", "#2929b8", "#8e39ee"], // Purple blue
[270, "#8447f0", "#7514a9", "#5924b8", "#b039ef"], // Purple
[330, "#f74bbb", "#ad174e", "#b72484", "#f13a77"], // Pink
]
// Generate a branding style from a file's properties map
export const branding_from_path = (path: Array<FSNode>) => {
const style = {}
@@ -57,7 +93,7 @@ export const css_from_path = (path: Array<FSNode>) => {
}
const gen_css = (style: Style) => {
return Object.entries(style).map(([key, value]) => `--${key}:${value}`).join(';');
return Object.entries(with_accents(style)).map(([key, value]) => `--${key}:${value}`).join(';');
}
// add_styles adds the styles configured in the properties struct to the
@@ -74,20 +110,33 @@ const add_styles = (style: Style, properties: FSNodeProperties) => {
style.input_hover_background = properties.brand_input_color
style.input_text = add_contrast(properties.brand_input_color, text_contrast)
}
if (properties.brand_highlight_color) {
style.highlight_color = properties.brand_highlight_color
style.highlight_background = properties.brand_highlight_color
style.highlight_text_color = add_contrast(properties.brand_highlight_color, text_contrast)
if (properties.brand_accent_color) {
style.accent_color = properties.brand_accent_color
style.accent_background = properties.brand_accent_color
style.accent_text_color = add_contrast(properties.brand_accent_color, text_contrast)
// If we have a body colour to compare it to we use the highlight colour
// If we have a body colour to compare it to we use the accent colour
// to generate the link colour
if (properties.brand_body_color) {
style.link_color = generate_link_color(properties.brand_highlight_color, properties.brand_body_color)
style.link_color = generate_link_color(properties.brand_accent_color, properties.brand_body_color)
}
// The logo gets the colourway closest to the accent colour. Greys
// have no meaningful hue, they keep the default
const [hue, saturation] = rgb2hsl(parse(properties.brand_accent_color))
if (saturation > 15) {
const distance = (h: number) => Math.min(Math.abs(h - hue), 360 - Math.abs(h - hue))
const way = logo_colourways.reduce((a, b) => distance(b[0]) < distance(a[0]) ? b : a)
style.logo_color_1 = way[1]
style.logo_color_2 = way[2]
style.logo_color_3 = way[3]
style.logo_color_4 = way[4]
}
}
if (properties.brand_danger_color) {
style.danger_color = properties.brand_danger_color
style.danger_text_color = add_contrast(properties.brand_danger_color, text_contrast)
for (const name of Object.keys(accent_hues)) {
if (properties["brand_" + name + "_color"]) {
style[name + "_color"] = properties["brand_" + name + "_color"]
}
}
if (properties.brand_background_color) {
style.background_color = properties.brand_background_color
@@ -102,6 +151,10 @@ const add_styles = (style: Style, properties: FSNodeProperties) => {
style.shaded_background = set_alpha(properties.brand_body_color, body_alpha)
style.separator = add_contrast(properties.brand_body_color, 15)
style.shadow_color = darken(properties.brand_body_color, 0.8)
// Makes the browser draw its own controls, like scrollbars and date
// pickers, in matching colours
style.color_scheme = rgb2hsl(parse(properties.brand_body_color))[2] < 50 ? "dark" : "light"
}
if (properties.brand_card_color) {
style.card_color = properties.brand_card_color
@@ -114,6 +167,45 @@ const add_styles = (style: Style, properties: FSNodeProperties) => {
}
}
// with_accents adds the accents for success, errors, warnings and notices, and
// their tints. Accents which the branding doesn't set get the saturation and
// lightness of the accent colour, so they match it. A light theme's
// accent colour is too light for text, those get fixed accents. A branded
// page always gets all accents, else a shared directory would inherit the
// accents of the visitor's own theme
const with_accents = (style: Style): Style => {
if (Object.keys(style).length === 0) {
return style
}
// Greys have no hue to match, those use the default accent colour
let [, saturation, lightness] = rgb2hsl(parse(style.accent_color ?? default_accent))
if (saturation <= 15) {
[, saturation, lightness] = rgb2hsl(parse(default_accent))
}
// Without its own background colours the branding is shown on the default
// theme, which follows the browser's preference
const light = (style.color_scheme ?? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")) === "light"
const full = { ...style }
// The background of selected buttons. A subtle dark shade of the accent on
// dark themes and a light one on light themes, so the accent is readable on it
const [accent_hue, accent_saturation] = rgb2hsl(parse(style.accent_color ?? default_accent))
full.accent_shade = rgb2hex(hsl2rgb([accent_hue, Math.min(accent_saturation, 25), light ? 91 : 19]))
for (const [name, hue] of Object.entries(accent_hues)) {
let colour = style[name + "_color"]
if (!colour) {
colour = light ? light_accents[name] : rgb2hex(hsl2rgb([hue, saturation, lightness]))
}
full[name + "_color"] = colour
full[name + "_tint"] = set_alpha(colour, 0.12)
}
return full
}
const add_contrast = (color: string, amt: number) => {
let hsl = rgb2hsl(parse(color)) // Convert hex to hsl
// If the lightness is less than 40 it is considered a dark colour. This

View File

@@ -67,7 +67,7 @@ const handle_picker = async (nodes: FSNode[]) => {
}
}
let highlight_info = $state(false)
let accent_info = $state(false)
</script>
<fieldset>
@@ -93,14 +93,14 @@ let highlight_info = $state(false)
<fieldset class="grid" disabled={!enabled}>
<legend>Custom colours</legend>
<div>
<div style="display: inline-block">Highlight</div>
<HelpButton bind:toggle={highlight_info}/>
<div style="display: inline-block">Accent</div>
<HelpButton bind:toggle={accent_info}/>
</div>
<input type="color" value={options.brand_highlight_color} onchange={e => options.brand_highlight_color = (e.target as HTMLInputElement).value}/>
<input type="text" bind:value={options.brand_highlight_color}/>
{#if highlight_info}
<input type="color" value={options.brand_accent_color} onchange={e => options.brand_accent_color = (e.target as HTMLInputElement).value}/>
<input type="text" bind:value={options.brand_accent_color}/>
{#if accent_info}
<p class="span3">
The highlight colour is used for highlighting selected buttons and
The accent colour is used for highlighting selected buttons and
other elements. It's also used as the page's theme colour, this
affects things like the embed colour in Discord and the colour of
the address bar in some web browsers.
@@ -109,9 +109,6 @@ let highlight_info = $state(false)
<div>Button and input</div>
<input type="color" value={options.brand_input_color} onchange={e => options.brand_input_color = (e.target as HTMLInputElement).value}/>
<input type="text" bind:value={options.brand_input_color}/>
<div>Delete button</div>
<input type="color" value={options.brand_danger_color} onchange={e => options.brand_danger_color = (e.target as HTMLInputElement).value}/>
<input type="text" bind:value={options.brand_danger_color}/>
<div>Background</div>
<input type="color" value={options.brand_background_color} onchange={e => options.brand_background_color = (e.target as HTMLInputElement).value}/>
<input type="text" bind:value={options.brand_background_color}/>
@@ -123,6 +120,26 @@ let highlight_info = $state(false)
<input type="text" bind:value={options.brand_card_color}/>
</fieldset>
<fieldset class="grid" disabled={!enabled}>
<legend>Accent colours</legend>
<p class="span3">
Colours for success messages, errors, warnings and notices. Colours you
leave empty will be derived from the accent colour, so they match it.
</p>
<div>Success</div>
<input type="color" value={options.brand_green_color} onchange={e => options.brand_green_color = (e.target as HTMLInputElement).value}/>
<input type="text" bind:value={options.brand_green_color}/>
<div>Error</div>
<input type="color" value={options.brand_red_color} onchange={e => options.brand_red_color = (e.target as HTMLInputElement).value}/>
<input type="text" bind:value={options.brand_red_color}/>
<div>Warning</div>
<input type="color" value={options.brand_yellow_color} onchange={e => options.brand_yellow_color = (e.target as HTMLInputElement).value}/>
<input type="text" bind:value={options.brand_yellow_color}/>
<div>Notice</div>
<input type="color" value={options.brand_blue_color} onchange={e => options.brand_blue_color = (e.target as HTMLInputElement).value}/>
<input type="text" bind:value={options.brand_blue_color}/>
</fieldset>
<fieldset class="grid" disabled={!enabled}>
<legend>Image customization</legend>
<p class="span3">
@@ -174,6 +191,13 @@ let highlight_info = $state(false)
<input type="text" value="A text field"/>
</div>
<div class="example_button_row" style="gap: 4px;">
<div class="highlight_green">Success</div>
<div class="highlight_red">Error</div>
<div class="highlight_yellow">Warning</div>
<div class="highlight_blue">Notice</div>
</div>
<hr/>
<div class="example example_card">

View File

@@ -12,6 +12,13 @@ let current_theme = $state(-1)
const set_theme = (index: number) => {
current_theme = index
// Presets without accents derive them from the accent colour, the
// accents of an earlier preset shouldn't stick
properties.brand_green_color = ""
properties.brand_red_color = ""
properties.brand_yellow_color = ""
properties.brand_blue_color = ""
// Copy all the properties of the theme to the file, except for the theme
// name
Object.keys(themes[index]).forEach(key => {
@@ -25,99 +32,91 @@ const themes = [
{
name: "PD classic",
brand_input_color: "#2d2d2d",
brand_highlight_color: "#75b72d",
brand_danger_color: "#821b3f",
brand_accent_color: "#75b72d",
brand_background_color: "#141414",
brand_body_color: "#1e1e1e",
brand_card_color: "#282828",
}, {
name: "Nord (dark)",
brand_input_color: "#4f596d",
brand_highlight_color: "#a4be8c",
brand_danger_color: "#bd5f69",
brand_accent_color: "#a4be8c",
brand_background_color: "#2f3541",
brand_body_color: "#3b4252",
brand_card_color: "#434c5f",
}, {
name: "Nord (light)",
brand_input_color: "#cad2e1",
brand_highlight_color: "#a4be8c",
brand_danger_color: "#bd5f69",
brand_accent_color: "#a4be8c",
brand_background_color: "#d7dde8",
brand_body_color: "#ebeef3",
brand_card_color: "#e5e9f0",
}, {
name: "Black",
brand_input_color: "#202020",
brand_highlight_color: "#57e389",
brand_danger_color: "#ed333b",
brand_accent_color: "#57e389",
brand_background_color: "#000000",
brand_body_color: "#080808",
brand_card_color: "#101010",
}, {
name: "White",
brand_input_color: "#f4f4f4",
brand_highlight_color: "#2ec27e",
brand_danger_color: "#c01c28",
brand_accent_color: "#2ec27e",
brand_background_color: "#f8f8f8",
brand_body_color: "#ffffff",
brand_card_color: "#f0f0f0",
}, {
name: "Solarized (dark)",
brand_input_color: "#084453",
brand_highlight_color: "#849900",
brand_danger_color: "#db302d",
brand_accent_color: "#849900",
brand_background_color: "#002c38",
brand_body_color: "#063540",
brand_card_color: "#073c49",
}, {
name: "Solarized (light)",
brand_input_color: "#e7dfc5",
brand_highlight_color: "#849900",
brand_danger_color: "#db302d",
brand_accent_color: "#849900",
brand_background_color: "#ede7d3",
brand_body_color: "#fdf5e2",
brand_card_color: "#fcf2d8",
}, {
name: "Mocha",
brand_input_color: "#313244",
brand_highlight_color: "#a6e3a1",
brand_danger_color: "#f38ba8",
brand_accent_color: "#a6e3a1",
brand_background_color: "#1e1e2e",
brand_body_color: "#181825",
brand_card_color: "#313244",
}, {
name: "Dracula",
brand_input_color: "#424450",
brand_highlight_color: "#50FA7B",
brand_danger_color: "#FF5555",
brand_accent_color: "#50FA7B",
brand_green_color: "#50FA7B",
brand_red_color: "#FF5555",
brand_yellow_color: "#F1FA8C",
brand_blue_color: "#8BE9FD",
brand_background_color: "#191A21",
brand_body_color: "#21222C",
brand_card_color: "#343746",
}, {
name: "Nova",
brand_input_color: "#28033f",
brand_highlight_color: "#57e389",
brand_danger_color: "#ed333b",
brand_background_color: "#0d0015",
brand_body_color: "#130020",
brand_card_color: "#1a002b",
name: "Deep purple",
brand_input_color: "#342848",
brand_accent_color: "#c49efa",
brand_background_color: "#160f1f",
brand_body_color: "#1d1429",
brand_card_color: "#281e38",
}, {
name: "Green Phosphor",
brand_input_color: "#001e09",
brand_highlight_color: "#57e389",
brand_danger_color: "#ed333b",
brand_background_color: "#000502",
brand_body_color: "#000903",
brand_card_color: "#001707",
brand_input_color: "#263129",
brand_accent_color: "#69d38c",
brand_background_color: "#0d120e",
brand_body_color: "#141a16",
brand_card_color: "#1d261f",
}, {
name: "Amber",
brand_input_color: "#3c2b00",
brand_highlight_color: "#f5c211",
brand_danger_color: "#c01c28",
brand_background_color: "#171100",
brand_body_color: "#201700",
brand_card_color: "#281d00",
brand_input_color: "#363026",
brand_accent_color: "#e2b05a",
brand_background_color: "#16130e",
brand_body_color: "#1f1b14",
brand_card_color: "#2a251d",
}
]
</script>

View File

@@ -62,7 +62,7 @@ let {
display: flex;
flex-direction: row;
text-decoration: none;
color: var(--body_text-color);
color: var(--body_text_color);
padding: 2px;
align-items: center;
background: var(--body_background);
@@ -78,8 +78,8 @@ let {
text-decoration: none;
}
.node.node_selected {
background-color: var(--highlight_color) !important;
color: var(--highlight_text_color);
background-color: var(--accent_color) !important;
color: var(--accent_text_color);
}
.node>* {
flex: 0 0 content;

View File

@@ -290,7 +290,7 @@ const move_here = async () => {
class="container"
use:drop_target={{
upload: (files) => upload_widget.upload_files(files),
shadow: "var(--highlight_color) 0 0 10px 2px inset",
shadow: "var(--accent_color) 0 0 10px 2px inset",
}}
>
<div class="width_container bubble">
@@ -443,10 +443,7 @@ const move_here = async () => {
text-align: center;
}
.toolbar_edit {
background-color: rgba(0, 255, 0, 0.05);
}
.icon.button_highlight {
border-radius: 4px;
background-color: var(--green_tint);
}
/* Large icon mode only supported on wide screens. Hide the button on small screen */

View File

@@ -237,7 +237,4 @@ onMount(() => {
text-align: center;
font-size: 1.2em;
}
.icon.button_highlight {
border-radius: 4px;
}
</style>

View File

@@ -95,8 +95,8 @@ let {
background: var(--input_hover_background);
}
.file.selected {
background: var(--highlight_background);
color: var(--highlight_text_color);
background: var(--accent_background);
color: var(--accent_text_color);
}
.node_icon {
flex: 1 1 0;

View File

@@ -202,7 +202,7 @@ td {
}
.node {
text-decoration: none;
color: var(--body_text-color);
color: var(--body_text_color);
cursor: pointer;
}
.node:not(:last-child) {
@@ -214,8 +214,8 @@ td {
text-decoration: none;
}
.node.node_selected {
background-color: var(--highlight_color) !important;
color: var(--highlight_text_color);
background-color: var(--accent_color) !important;
color: var(--accent_text_color);
}
td {
vertical-align: middle;

View File

@@ -178,7 +178,7 @@ onMount(() => {
flex: 1 1 auto;
}
.error {
color: var(--danger_color, #c0392b);
color: var(--red_color);
}
.trash_table {
width: 100%;

View File

@@ -99,7 +99,7 @@ const cancel = () => {
align-items: center; /* Stop stretching the button */
}
.error {
background: var(--danger_color);
color: var(--highlight_text_color);
background: var(--red_tint);
color: var(--red_color);
}
</style>

View File

@@ -186,6 +186,6 @@ onMount(() => {
margin: 4px;
}
.playing {
color: var(--highlight_color);
color: var(--accent_color);
}
</style>

View File

@@ -206,7 +206,7 @@ const video_keydown = (e: KeyboardEvent) => {
.compatibility_warning {
background-color: var(--shaded_background);
backdrop-filter: blur(4px);
border-bottom: 2px solid #6666FF;
border-bottom: 2px solid var(--blue_color);
padding: 4px;
}
</style>

14
svelte/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
import type { User } from "lib/NovaAPI"
import type { FSPath } from "lib/FilesystemAPI.svelte"
// Values which the server embeds in the page, see res/template/wrap.html
declare global {
interface Window {
api_endpoint: string
server_hostname: string
// Only on filesystem pages. null means the user is not logged in
user?: User | null
initial_node?: FSPath
}
}

View File

@@ -333,7 +333,7 @@ import { user } from "lib/UserStore";
border: 1px solid var(--background_color);
}
.pro_feat {
border: 1px solid var(--highlight_color);
border: 1px solid var(--accent_color);
}
.free_feat {
border: 1px solid #ebcb8b;
@@ -345,9 +345,9 @@ import { user } from "lib/UserStore";
grid-column: span 2;
}
.span2.left {
border-image: linear-gradient(to right, #ebcb8b 0%, var(--highlight_color) 100%) 1;
border-image: linear-gradient(to right, #ebcb8b 0%, var(--accent_color) 100%) 1;
}
.span2.right {
border-image: linear-gradient(to right, var(--highlight_color) 0%, #ec2cfa 100%) 1;
border-image: linear-gradient(to right, var(--accent_color) 0%, #ec2cfa 100%) 1;
}
</style>

View File

@@ -1,16 +1,16 @@
<script>
import Menu from "filesystem/Menu.svelte";
import Footer from "layout/Footer.svelte";
import { drop_target } from "lib/DropTarget";
import GetStarted from "./GetStarted.svelte";
import Pricing from "./Pricing.svelte";
import SubscriptionPlans from "layout/checkout/SubscriptionPlans.svelte";
import NovaLogo from "util/NovaLogo.svelte";
let upload_widget
</script>
<header class="logo_header">
<div class="header_image_container"></div>
<NovaLogo full style="display: block; height: 100px; max-width: 100%; margin: 50px auto;"/>
</header>
<div class="page_content">
@@ -60,17 +60,6 @@ let upload_widget
</header>
<SubscriptionPlans/>
<svelte:head>
<style>
body {
background-image: url("/res/img/northernlights.webp");
background-repeat: no-repeat;
background-attachment: fixed;
background-position: center;
background-size: cover;
}
</style>
</svelte:head>
<style>
header {
padding-top: 20px;
@@ -82,21 +71,8 @@ header {
padding-bottom: 0;
}
header > h1 {
color: #ffffff;
text-shadow: 1px 1px 5px #000000;
margin-top: 30px;
margin-bottom: 30px;
}
.header_image_container {
text-align: initial;
margin: 50px auto;
height: 100px;
width: 500px;
max-width: 100%;
background-image: url("/res/img/header.webp");
background-repeat: no-repeat;
background-size: contain;
background-position: center;
}
</style>

View File

@@ -1,14 +0,0 @@
<script lang="ts">
let { title = "" }: {
title?: string;
} = $props();
</script>
<h1>{title}</h1>
<style>
h1 {
text-shadow: 1px 1px 2px #000000;
line-break: anywhere;
}
</style>

View File

@@ -42,9 +42,9 @@ const click = (e: MouseEvent) => {
class:flat
>
{#if on}
<i class="icon green">{icon_on}</i>
<i class="icon on">{icon_on}</i>
{:else}
<i class="icon red">{icon_off}</i>
<i class="icon off">{icon_off}</i>
{/if}
{@render children?.()}
</button>
@@ -53,10 +53,10 @@ const click = (e: MouseEvent) => {
.button {
flex: 0 0 content;
}
.green {
color: var(--highlight_color);
.on {
color: var(--accent_color);
}
.red {
color: var(--danger_color);
.off {
color: var(--red_color);
}
</style>

View File

@@ -280,7 +280,7 @@ const submit = async (e: SubmitEvent) => {
<style>
.date {
color: var(--highlight_color);
color: var(--accent_color);
}
/* layout.css collapses the table borders, which makes the browser ignore any
padding on the table itself. The cells line the table up with the rest of

View File

@@ -94,7 +94,7 @@ get_misc_subscriptions().then(v => plans = v).catch(err => {
background-color: var(--body_color);
}
.plan.highlighted {
border-color: var(--highlight_color);
border-color: var(--accent_color);
}
.plan > div {
flex: 1 1 0;

View File

@@ -30,7 +30,7 @@ export const drop_target = (
e.stopPropagation();
e.preventDefault();
if (args.shadow === undefined) {
node.style.boxShadow = "var(--highlight_color) 0 0 2px 2px"
node.style.boxShadow = "var(--accent_color) 0 0 2px 2px"
} else {
node.style.boxShadow = args.shadow
}

View File

@@ -87,8 +87,11 @@ export class FSNode {
export type FSNodeProperties = {
branding_enabled?: string,
brand_input_color?: string,
brand_highlight_color?: string,
brand_danger_color?: string,
brand_accent_color?: string,
brand_green_color?: string,
brand_red_color?: string,
brand_yellow_color?: string,
brand_blue_color?: string,
brand_background_color?: string,
brand_body_color?: string,
brand_card_color?: string,
@@ -168,10 +171,13 @@ export const fs_mkdirall = async (path: string, opts: NodeOptions) => {
}
export const fs_get_node = async (path: string) => {
const resp = await fs_check_response(
return fs_path_from_json(await fs_check_response(
await fetch(fs_path_url(path) + "?stat")
) as FSPath
))
}
// Turns the nodes in a decoded API response into FSNode objects
export const fs_path_from_json = (resp: FSPath) => {
const fsp = new FSPath()
fsp.path = new Array(resp.path.length)
resp.path.forEach((node, index) => {

View File

@@ -4,11 +4,12 @@ import { current_page_store } from "wrap/RouterStore"
export const highlight_current_page = (node: HTMLAnchorElement) => {
const set_highlight = () => {
const currentpath = window.location.pathname + "/"
const hrefpath = URL.parse(node.href).pathname + "/"
if (
const hrefpath = node.pathname + "/"
// Links to other sites, like the documentation, are never the current page
if (node.host === window.location.host && (
currentpath === hrefpath ||
(hrefpath !== "" && hrefpath !== "/" && currentpath.startsWith(hrefpath))
) {
)) {
node.classList.add("button_highlight")
} else {
node.classList.remove("button_highlight")

View File

@@ -51,21 +51,8 @@ export type Subscription = {
// Utility funcs
// =============
export const get_endpoint = () => {
if ((window as any).api_endpoint !== undefined) {
return (window as any).api_endpoint as string
}
console.warn("api_endpoint property is not defined on window")
return "/api"
}
export const get_hostname = () => {
if ((window as any).server_hostname !== undefined) {
return (window as any).server_hostname as string
}
console.warn("server_hostname property is not defined on window")
return "undefined"
}
export const get_endpoint = () => window.api_endpoint
export const get_hostname = () => window.server_hostname
export const check_response = async (resp: Response) => {
let text = await resp.text()
@@ -111,8 +98,8 @@ export type UserSession = {
}
// If cached_user is undefined it means that the value is not initialized yet,
// null means the user is not logged in
let cached_user: User = undefined
// null means the user is not logged in. Some pages have it embedded by the server
let cached_user: User = window.user
// The request which is currently in flight. Callers which arrive while the
// request is still running share this promise, so the user is only requested

View File

@@ -1,145 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
const get_cookie = (cname: string) => {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for(let i = 0; i <ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
let style = get_cookie("style")
let hue = +get_cookie("hue")
// Style selector
onMount(() => {
document.getElementsByName("style").forEach(function(elem) {
elem.addEventListener("change", e => {
style = (e.target as HTMLInputElement).id.substring(6)
var date = new Date();
date.setTime(date.getTime() + (10 * 365 * 24 * 60 * 60 * 1000));
document.cookie = "style="+style+"; expires=" + date.toUTCString() + "; path=/"
reload_sheet()
})
});
document.getElementsByName("hue").forEach(function(elem) {
elem.addEventListener("change", e => {
hue = +(e.target as HTMLInputElement).id.substring(4)
if (hue === -1) {
document.cookie = "hue=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/"
}
var date = new Date();
date.setTime(date.getTime() + (10 * 365 * 24 * 60 * 60 * 1000));
document.cookie = "hue="+hue+"; expires=" + date.toUTCString() + "; path=/"
reload_sheet()
})
});
})
let stylesheet_2: HTMLLinkElement = $state()
const reload_sheet = () => {
let stylesheet1 = document.getElementById("stylesheet_theme") as HTMLLinkElement
// First load the sheet in the secondary tag, wait for it to load,
// and replace the original sheet when it has finished loading
stylesheet_2.href= "/theme.css?style="+style+"&hue="+hue
stylesheet_2.onload = e => {
stylesheet1.href= "/theme.css?style="+style+"&hue="+hue
}
}
</script>
<svelte:head>
<link bind:this={stylesheet_2} id="stylesheet_theme_2" rel="stylesheet" type="text/css" href="/theme.css"/>
</svelte:head>
<div id="page_content" class="page_content">
<section>
<p>
You can change how Nova looks! Your theme choice will
be saved in a cookie.
</p>
<h2>Theme</h2>
<input type="radio" id="style_nord" name="style"><label for="style_nord">Nord</label>
(Inspired by <a href="https://www.nordtheme.com/" target="_blank">Nord</a>)
<br/>
Dynamic theme, changes based on operating system settings. Here you can choose a specific variant:
<br/>
<input type="radio" id="style_nord_dark" name="style"><label for="style_nord_dark">Nord dark</label>
<br/>
<input type="radio" id="style_nord_light" name="style"><label for="style_nord_light">Nord light</label>
<br/>
<br/>
<input type="radio" id="style_solarized" name="style"><label for="style_solarized">Solarized</label>
(Inspired by <a href="https://ethanschoonover.com/solarized/" target="_blank">Solarized</a>)
<br/>
Dynamic theme, changes based on operating system settings. Here you can choose a specific variant:
<br/>
<input type="radio" id="style_solarized_dark" name="style"><label for="style_solarized_dark">Solarized dark</label>
<br/>
<input type="radio" id="style_solarized_light" name="style"><label for="style_solarized_light">Solarized light</label>
<br/>
<!-- <br/> -->
<!-- <input type="radio" id="style_adwaita" name="style"><label for="style_adwaita">Adwaita</label><br/> -->
<br/>
<input type="radio" id="style_purple_drain" name="style"><label for="style_purple_drain">Purple drain</label>
<br/>
Classic 2022 style, with purple gradients
<br/>
<br/>
<input type="radio" id="style_classic" name="style"><label for="style_classic">Nova classic (gray)</label>
<br/>
Classic pre-2020 Nova style, dark gray
<br/>
<br/>
Other (experimental) themes
<br/>
<input type="radio" id="style_maroon" name="style"><label for="style_maroon">Maroon Style</label>
<br/>
<input type="radio" id="style_hacker" name="style"><label for="style_hacker">Hacker Style</label>
<br/>
<input type="radio" id="style_canta" name="style"><label for="style_canta">Canta Style</label>
(Inspired by <a href="https://github.com/vinceliuice/Canta-theme" target="_blank">Canta GTK</a>)
<br/>
<input type="radio" id="style_skeuos" name="style"><label for="style_skeuos">Skeuos Style</label>
(Inspired by <a href="https://www.gnome-look.org/p/1441725/" target="_blank">Skeuos GTK</a>)
<br/>
<input type="radio" id="style_sweet" name="style"><label for="style_sweet">Sweet</label>
<br/>
<br/>
<input type="radio" id="style_adwaita" name="style"><label for="style_adwaita">Adwaita (dynamic)</label>
<br/>
<input type="radio" id="style_adwaita_dark" name="style"><label for="style_adwaita_dark">Adwaita dark</label>
<br/>
<input type="radio" id="style_adwaita_light" name="style"><label for="style_adwaita_light">Adwaita light</label>
<br/><br/>
<input type="radio" id="style_nova98" name="style"><label for="style_nova98">Nova 98</label>
<br/>
<input type="radio" id="style_nova98_dark" name="style"><label for="style_nova98_dark">Nova 98 (dark)</label>
<h2>Hue</h2>
<p>
Many themes support custom hues. The hue does not change the
contrast of the theme, only the color itself.
</p>
<input type="radio" id="hue_-1" name="hue"><label for="hue_-1">Default</label><br/>
<input type="radio" id="hue_354" name="hue"><label for="hue_354">Red</label><br/>
<input type="radio" id="hue_14" name="hue"><label for="hue_14">Orange</label><br/>
<input type="radio" id="hue_40" name="hue"><label for="hue_40">Yellow</label><br/>
<input type="radio" id="hue_92" name="hue"><label for="hue_92">Green</label><br/>
<input type="radio" id="hue_311" name="hue"><label for="hue_311">Purple</label><br/>
</section>
</div>

View File

@@ -3,6 +3,7 @@ import { preventDefault } from 'svelte/legacy';
import { loading_finish, loading_start } from "lib/Loading";
import { formatDate } from "util/Formatting";
import CopyButton from 'layout/CopyButton.svelte';
import NovaLogo from "util/NovaLogo.svelte";
let loaded = $state(false)
let rows = $state([])
@@ -147,7 +148,7 @@ const logout = async (key) => {
<tr>
<td colspan="1">
{#if row.app_name === "website login"}
<img src="/res/img/nova_32.png" alt="Nova logo" class="app_icon"/>
<NovaLogo style="height: 1.6em; vertical-align: middle;"/>
Nova website
{:else if row.app_name === "website keys page"}
<i class="icon">vpn_key</i>

View File

@@ -52,8 +52,8 @@ let included = $derived($user === null ? 0 : $user.subscription.monthly_transfer
</p>
{:else}
<p>
Upgrade your plan{#if billable} or enable
<a href="/user/sharing">usage based billing</a>{/if} to continue
Upgrade your plan or enable
<a href="/user/sharing">usage based billing</a> to continue
sharing files.
</p>
<a class="button button_highlight" href="/user/subscription/manage">

View File

@@ -40,9 +40,9 @@ let billed = $derived(billable && $user.usage_based_billing)
<p>
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
upgrade your plan to continue uploading, or enable
<a href="/user/sharing">usage based billing</a> to pay for the
storage above the limit{/if}.
storage above the limit.
</p>
<a class="button button_highlight" href="/user/subscription/manage">
<i class="icon">bolt</i> Upgrade options

View File

@@ -84,6 +84,6 @@ onMount(async () => {
border-radius: 6px;
}
.red {
background-color: rgba(255, 0, 0, 0.2);
background-color: var(--red_tint);
}
</style>

View File

@@ -87,8 +87,8 @@ onMount(() => {
label: "Downloads",
borderWidth: 2,
pointRadius: 0,
borderColor: color_by_name("highlight_color"),
backgroundColor: color_by_name("highlight_color"),
borderColor: color_by_name("accent_color"),
backgroundColor: color_by_name("accent_color"),
},
];
graph_egress.data().datasets = [
@@ -96,8 +96,8 @@ onMount(() => {
label: "Egress",
borderWidth: 2,
pointRadius: 0,
borderColor: color_by_name("highlight_color"),
backgroundColor: color_by_name("highlight_color"),
borderColor: color_by_name("accent_color"),
backgroundColor: color_by_name("accent_color"),
},
];

View File

@@ -8,16 +8,16 @@ let { symbol = "€", amount = 0, precision = 2 }: {
<span>
{symbol}
<span class:red={amount < 0} class:green={amount > 0}>
<span class:negative={amount < 0} class:positive={amount > 0}>
{(amount / 1000000).toLocaleString(undefined, {minimumFractionDigits: precision, maximumFractionDigits: precision})}
</span>
</span>
<style>
.red {
color: var(--danger_color);
.negative {
color: var(--red_color);
}
.green {
color: var(--highlight_color);
.positive {
color: var(--accent_color);
}
</style>

View File

@@ -1,21 +1,77 @@
<script lang="ts">
let { style = "" } = $props();
</script>
<svg style={style} enable-background="new 0 0 283.46 283.46" version="1.1" viewBox="0 0 288 288" xmlns="http://www.w3.org/2000/svg">
<path d="m142 2.23c-77 0-139 62.5-139 140 0 77 62.5 139 139 139 77 0 139-62.5 139-139 1e-3 -77-62.5-140-139-140zm0 258c-65.7 0-119-53.2-119-119s53.2-119 119-119c65.7 0 119 53.2 119 119 0 65.7-53.2 119-119 119zm0-219c-55.1 0-99.8 44.7-99.8 99.8 0 55.1 44.7 99.8 99.8 99.8s99.8-44.7 99.8-99.8c0-55.1-44.7-99.8-99.8-99.8zm49.3 36c8.69 0 15.7 7.04 15.7 15.7 0 8.69-7.04 15.7-15.7 15.7s-15.7-7.04-15.7-15.7c0-8.69 7.04-15.7 15.7-15.7zm-49.3-20c8.69 0 15.7 7.04 15.7 15.7 0 8.69-7.04 15.7-15.7 15.7s-15.7-7.04-15.7-15.7c0-8.69 7.04-15.7 15.7-15.7zm-48.7 20c8.69 0 15.7 7.04 15.7 15.7 0 8.69-7.04 15.7-15.7 15.7s-15.7-7.04-15.7-15.7c-1e-3 -8.69 7.04-15.7 15.7-15.7zm-35 63.8c0-8.69 7.04-15.7 15.7-15.7s15.7 7.04 15.7 15.7c0 8.69-7.04 15.7-15.7 15.7-8.69 0-15.7-7.04-15.7-15.7zm35 65.6c-8.69 0-15.7-7.04-15.7-15.7s7.04-15.7 15.7-15.7 15.7 7.04 15.7 15.7-7.04 15.7-15.7 15.7zm48.7 20.7c-8.69 0-15.7-7.04-15.7-15.7 0-8.69 7.04-15.7 15.7-15.7 8.69 0 15.7 7.04 15.7 15.7 1e-3 8.68-7.04 15.7-15.7 15.7zm2e-3 -47c-21.2 0-38.5-17.2-38.5-38.5 0-21.2 17.2-38.5 38.5-38.5 21.2 0 38.5 17.2 38.5 38.5 0 21.2-17.2 38.5-38.5 38.5zm49.3 26.3c-8.69 0-15.7-7.04-15.7-15.7s7.04-15.7 15.7-15.7 15.7 7.04 15.7 15.7-7.04 15.7-15.7 15.7zm18.6-49.9c-8.69 0-15.7-7.04-15.7-15.7 0-8.69 7.04-15.7 15.7-15.7s15.7 7.04 15.7 15.7c0 8.69-7.04 15.7-15.7 15.7z"/>
</svg>
<script lang="ts" module>
// The logo as designed, with the colours of one of its colourways. The bars
// fade to black, and the thin edges and the lettering use the text colour, so
// the logo works on dark and light themes
const markup = (id: string, colours: string[], full: boolean) => `
<defs>
<linearGradient id="${id}bar" x1="27.2" y1="44.04" x2="27.2" y2="16.51" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:${colours[0]}"/>
<stop offset=".5" style="stop-color:${colours[1]}"/>
<stop offset="1" style="stop-color:#000"/>
</linearGradient>
<linearGradient id="${id}bar2" x1="50.8" y1="13.18" x2="50.8" y2="40.72" href="#${id}bar"/>
<linearGradient id="${id}diagonal" x1="24.86" y1="28.61" x2="53.14" y2="28.61" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:${colours[2]}"/>
<stop offset="1" style="stop-color:${colours[3]}"/>
</linearGradient>
<linearGradient id="${id}edge" x1="27.2" y1="16.42" x2="27.2" y2="44.13" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:currentColor;stop-opacity:0"/>
<stop offset="1" style="stop-color:currentColor"/>
</linearGradient>
<linearGradient id="${id}edge2" x1="50.8" y1="40.81" x2="50.8" y2="13.09" href="#${id}edge"/>
<linearGradient id="${id}edge3" x1="24.77" y1="28.61" x2="53.23" y2="28.61" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:currentColor;stop-opacity:0"/>
<stop offset=".54" style="stop-color:currentColor"/>
<stop offset="1" style="stop-color:currentColor;stop-opacity:0"/>
</linearGradient>
</defs>
<g stroke-width=".18" stroke-miterlimit="10">
<path fill="url(#${id}bar)" stroke="url(#${id}edge)" d="M27.96,16.75c-2.08,0-2.93,1.83-3.11,3v21.81c0,1.37,1.05,2.48,2.34,2.48s2.34-1.11,2.34-2.48v-24.76c-.58-.63-.72-.05-1.57-.05Z"/>
<path fill="url(#${id}bar2)" stroke="url(#${id}edge2)" d="M50.03,40.48c2.08,0,2.93-1.83,3.11-3v-.54s0-21.28,0-21.28c0-1.37-1.05-2.48-2.34-2.48s-2.34,1.11-2.34,2.48v20.98s0,2.54,0,2.54v1.24c.58.63.72.05,1.57.05Z"/>
<path fill="url(#${id}diagonal)" stroke="url(#${id}edge3)" d="M50.03,40.49c-.71,0-1.33-.32-1.86-.77-.18-.15-.33-.33-.45-.53l-1-1.66-12.34-21.84c-.8-1.43-2.19-2.5-3.83-2.5-.34,0-.68.05-1.01.13-2.49.63-4.68,3.44-4.68,5.96h0v.29c.18-1.1,1.03-2.83,3.11-2.83.71,0,1.33.32,1.86.77.18.15.33.33.45.53l1,1.66,12.34,21.84c.8,1.43,2.19,2.5,3.83,2.5.34,0,.68-.05,1.01-.13,2.49-.63,4.68-3.44,4.68-5.96h0v-.29c-.18,1.1-1.03,2.83-3.11,2.83Z"/>
</g>
${full ? lettering : ""}`
<style>
svg {
fill: currentColor;
animation: rotation 20s infinite linear;
const lettering = `
<g fill="currentColor">
<path d="M57.91,33.07c0-6.24,4.33-11.02,11.5-11.02s11.5,4.78,11.5,11.02-4.33,11.02-11.5,11.02-11.5-4.78-11.5-11.02ZM76.29,33.07c0-3.54-2.24-6.84-6.84-6.84s-6.87,3.3-6.87,6.84,2.24,6.84,6.87,6.84,6.84-3.3,6.84-6.84Z"/>
<path d="M81.81,22.48h4.71c.09,0,.17.06.2.14l5.52,14.84c.08.22.4.22.48,0l5.55-14.84c.03-.08.11-.14.2-.14h4.65c.15,0,.26.15.2.3l-7.31,18.47c-.58,1.46-1.99,2.42-3.56,2.42h0c-1.57,0-2.98-.96-3.56-2.42l-7.28-18.47c-.06-.14.05-.3.2-.3Z"/>
<path d="M116.86,24.92c-.67-1.49-2.15-2.44-3.78-2.44s-3.11.96-3.78,2.44l-8.31,18.46c-.06.13.04.28.18.28h4.55c.08,0,.16-.05.19-.12l6.62-16.01c.18-.42.78-.42.95,0l3.25,7.81h0l3.4,8.2c.03.08.1.12.19.12h4.67c.15,0,.24-.15.18-.28l-8.31-18.46Z"/>
<polygon points="112.36 35.47 113.08 33.53 113.8 35.47 115.74 36.18 113.8 36.9 113.08 38.84 112.36 36.9 110.43 36.18 112.36 35.47"/>
<path d="M31.22,55.07c0,1.29-.53,1.75-1.97,1.75h-4.28c.02-.4.05-.79.08-1.19h4.12c.64,0,.83-.15.83-.67s-.18-.67-.8-.67h-2.46c-1.45,0-1.92-.52-1.92-1.63v-.2c0-1.21.48-1.7,1.96-1.7h4.25c-.04.41-.1.8-.15,1.21h-4.1c-.58,0-.75.19-.75.66s.17.67.75.67h2.42c1.51,0,2.03.49,2.03,1.56v.21Z"/>
<path d="M42.35,56.82v-4.86h-2.21v-1.2h5.61v1.2h-2.21v4.86h-1.19Z"/>
<path d="M56.73,56.82c-1.47,0-1.89-.48-1.89-1.97v-2.13c0-1.49.42-1.97,1.89-1.97h3.2c1.44,0,1.89.48,1.89,1.97v2.13c0,1.49-.45,1.97-1.89,1.97h-3.2ZM60.63,52.73c0-.6-.19-.77-.71-.77h-3.19c-.53,0-.71.18-.71.77v2.13c0,.6.17.77.71.77h3.19c.55,0,.71-.17.71-.77v-2.13Z"/>
<path d="M76.43,56.82l-2.1-1.98h-1.91v1.98h-1.2v-6.06h4.95c1.29,0,1.74.5,1.74,1.88v.37c0,1.48-.56,1.82-2.05,1.82l2.25,2h-1.67ZM76.73,52.84c0-.59-.07-.87-.59-.87h-3.72v1.88h3.72c.42,0,.59-.19.59-.71v-.29Z"/>
<path d="M93.29,56.82l-.76-1.41h-3.66l-.76,1.41h-1.36l3.32-6.06h1.24l3.32,6.06h-1.35ZM90.71,51.95l-1.38,2.51h2.75l-1.37-2.51Z"/>
<path d="M108.94,56.82l-.04-.74c-.08.35-.48.74-1.16.74h-2.57c-1.5,0-1.88-.45-1.88-1.97v-2.13c0-1.45.39-1.97,1.88-1.97h3.08c.89,0,1.31.55,1.45.89l-.75.78c-.11-.24-.31-.47-.78-.47h-2.99c-.55,0-.71.17-.71.77v1.96c0,.84.15.95.71.95h2.76c.67,0,.89-.2.89-.57v-.78h-2.27v-.99h3.39v3.53h-1Z"/>
<path d="M119.33,56.82v-6.06h5.82v1.2h-4.64v1.34h4.51v.98h-4.51v1.36h4.64v1.19h-5.82Z"/>
</g>`
// The mark on its own is square, the full logo has the lettering next to and
// below it
const view_box = (full: boolean) => full ? "24.6 12.9 100.8 44.2" : "23.47 13.09 31.04 31.04"
// A favicon is drawn as a separate image, so it can't use the theme's CSS
// variables. This gives it the colours of the current theme instead
export const update_favicon = () => {
const style = getComputedStyle(document.body)
const colours = [1, 2, 3, 4].map(i => style.getPropertyValue("--logo_color_" + i))
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${view_box(false)}">${markup("f", colours, false)}</svg>`
document.querySelector<HTMLLinkElement>("link[type='image/svg+xml']").href = "data:image/svg+xml," + encodeURIComponent(svg)
}
@keyframes rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(359deg);
}
}
</style>
</script>
<script lang="ts">
let { full = false, style = "" }: {
full?: boolean
style?: string
} = $props()
// Gradient IDs have to be unique in the page, and the logo can be on the page
// more than once
const id = $props.id()
</script>
<svg viewBox={view_box(full)} style={style} role="img" aria-label="Nova.storage">
{@html markup(id, ["var(--logo_color_1)", "var(--logo_color_2)", "var(--logo_color_3)", "var(--logo_color_4)"], full)}
</svg>

View File

@@ -37,6 +37,7 @@ $effect(() => {
<div
class="progress_bar_inner"
class:no_animation
class:yellow={percent > 80}
class:red={percent > 90}
style="width: {percent}%; transition-timing-function: {animation}; transition-duration: {speed}ms;">
</div>
@@ -53,14 +54,17 @@ $effect(() => {
margin: 6px 0;
}
.progress_bar_inner {
background: var(--highlight_background);
background: var(--accent_background);
height: 100%;
width: 0;
border-radius: 6px;
transition-property: width;
}
.yellow {
background: var(--yellow_color);
}
.red {
background: var(--danger_color);
background: var(--red_color);
}
.no_animation {
transition-property: none;

View File

@@ -33,7 +33,7 @@
<style>
svg {
color: var(--highlight_color);
color: var(--accent_color);
fill: currentColor;
}
</style>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -125,7 +125,4 @@ header, .submenu {
flex-direction: column;
gap: 0;
}
.button_highlight {
background: var(--body_background);
}
</style>

View File

@@ -26,9 +26,13 @@ export function domain_url() {
return url;
}
// Theme colours can be functions like light-dark(), which only a real colour
// property can resolve. The body can have a shared directory's branding
export const color_by_name = (name: string) => {
return getComputedStyle(document.documentElement).getPropertyValue("--" + name);
}
export const color_by_name_no_prefix = (name: string) => {
return color_by_name(name).replace(/^#/, "");
const probe = document.createElement("span")
probe.style.color = "var(--" + name + ")"
document.body.append(probe)
const color = getComputedStyle(probe).color
probe.remove()
return color
}

View File

@@ -144,7 +144,7 @@ const drop = (e: DragEvent, drop_idx: number) => {
box-shadow: none;
}
.highlight {
box-shadow: 0 0 0px 1px var(--highlight_color);
box-shadow: 0 0 0px 1px var(--accent_color);
text-decoration: none;
}
.thumbnail {

View File

@@ -19,6 +19,7 @@ import MenuEntry from "./MenuEntry.svelte";
import { writable } from "svelte/store";
import { breadcrumbs_store } from "./BreadcrumbStore";
import ProgressBar from "util/ProgressBar.svelte";
import NovaLogo, { update_favicon } from "util/NovaLogo.svelte";
// The menu swipe will be detected if it was less than this much pixels from the
// screen edge
@@ -38,13 +39,24 @@ onMount(async () => {
menu_close()
}
// The page template already applied the branding saved on the previous
// visit, the favicon follows it
update_favicon()
await loading_run(async () => {
const user = await get_user()
if (user.username === undefined || user.username === "") {
if (!user?.username) {
// Forget the branding of a user who logged out
localStorage.removeItem("branding")
document.documentElement.style = ""
update_favicon()
return
}
const root = await fs_get_node("/me")
document.documentElement.style = css_from_path(root.path)
const branding = css_from_path(root.path)
document.documentElement.style = branding
localStorage.setItem("branding", branding)
update_favicon()
})
})
@@ -166,7 +178,7 @@ const set_offset = (off: number) => {
<a class="button" style="flex: 1 1 auto;" href="/" use:highlight_current_page>
<span style="flex: 1 1 auto;">Nova.storage</span>
<img src="/res/img/nova_64.png" style="width: 1.5em; height: 1.5em; flex: 0 0 auto;" alt="Nova.storage logo">
<NovaLogo style="width: 1.5em; height: 1.5em; flex: 0 0 auto;"/>
</a>
{#if $user !== null}
@@ -252,10 +264,6 @@ const set_offset = (off: number) => {
<i class="icon">speed</i>
<span>Speedtest</span>
</a>
<a class="button" href="/appearance" use:highlight_current_page>
<i class="icon">palette</i>
<span>Themes</span>
</a>
<a class="button" href="https://docs.nova.storage" use:highlight_current_page>
<i class="icon">text_snippet</i>
<span>Documentation</span>
@@ -299,7 +307,8 @@ const set_offset = (off: number) => {
flex-direction: row;
color: var(--body_text_color);
background-image: var(--background_image);
/* Without a branding image there's a faint glow in the accent colour */
background-image: var(--background_image, radial-gradient(ellipse 70% 60% at 15% 0%, color-mix(in srgb, var(--accent_color) 7%, transparent), transparent));
background-color: var(--background_color);
background-size: var(--background_image_size, initial);
background-position: var(--background_image_position, initial);

View File

@@ -1,58 +1,50 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import HomePage from "home_page/HomePage.svelte";
import LoginRouter from "login/Router.svelte";
import HomeRouter from "user_home/Router.svelte";
import AdminRouter from "admin_panel/Router.svelte";
import Filesystem from "filesystem/Filesystem.svelte";
import { onMount, tick, type Component } from "svelte";
import NotFound from "./NotFound.svelte";
import SpeedtestPage from "speedtest/SpeedtestPage.svelte";
import Appearance from "pages/Appearance.svelte";
import Footer from "layout/Footer.svelte";
import { current_page_store, type Tab } from "./RouterStore";
import { get_user, type User } from "lib/NovaAPI";
import { breadcrumbs_store } from "./BreadcrumbStore";
import { loading_run } from "lib/Loading";
// Pages are downloaded when they are opened for the first time. Someone who
// opens a shared file doesn't have to download the dashboard and admin panel
let pages: Tab[] = [
{
path: "/",
title: "Home",
component: HomePage,
component: () => import("home_page/HomePage.svelte"),
}, {
path: "/login",
title: "Login",
component: LoginRouter,
component: () => import("login/Router.svelte"),
}, {
path: "/register",
title: "Register",
component: LoginRouter,
component: () => import("login/Router.svelte"),
}, {
path: "/user",
prefix: "/user/",
title: "Dashboard",
component: HomeRouter,
component: () => import("user_home/Router.svelte"),
login: true,
}, {
path: "/d/me",
prefix: "/d/",
title: "Filesystem",
component: Filesystem,
component: () => import("filesystem/Filesystem.svelte"),
footer: false,
custom_breadcrumbs: true,
}, {
path: "/admin",
prefix: "/admin/",
title: "Admin Panel",
component: AdminRouter,
component: () => import("admin_panel/Router.svelte"),
login: true,
}, {
path: "/speedtest",
title: "Speedtest",
component: SpeedtestPage,
}, {
path: "/appearance",
title: "Appearance",
component: Appearance,
component: () => import("speedtest/SpeedtestPage.svelte"),
},
]
@@ -61,6 +53,12 @@ onMount(async () => {
})
let current_page: Tab = $state(null)
let Page: Component = $state(null)
// Every navigation gets a number. A navigation which is still loading when a
// newer one starts is abandoned, else it would replace the newer page when it
// finishes
let navigation_id = 0
// 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
@@ -80,7 +78,8 @@ const find_page = (pathname: string): Tab => {
// 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<boolean> => {
const load_page = async (href: string, history: boolean) => {
const id = ++navigation_id
const url = new URL(href, window.location.href)
console.debug(
"Page router: Navigating to page", url.pathname,
@@ -93,11 +92,14 @@ const load_page = async (href: string, history: boolean): Promise<boolean> => {
current_page = {
path: "",
title: "Not Found",
component: NotFound,
}
return false
Page = NotFound
return
}
// Download the page while we check the login
const component = loading_run(page.component)
let user: User
try {
user = await get_user()
@@ -116,6 +118,22 @@ const load_page = async (href: string, history: boolean): Promise<boolean> => {
)
}
let new_page: Component
try {
new_page = (await component).default
} catch (err) {
// An update deletes the code of the previous version from the server.
// Tabs which were opened before the update need a full page load
if (current_page === null) {
throw err
}
window.location.assign(url)
return
}
if (id !== navigation_id) {
return
}
window.document.title = page.title+" / Nova"
// The address bar has to be updated before the page is mounted. Pages read
@@ -128,6 +146,7 @@ const load_page = async (href: string, history: boolean): Promise<boolean> => {
}
current_page = page
Page = new_page
if (current_page.custom_breadcrumbs !== true) {
breadcrumbs_store.set(breadcrumbs)
@@ -140,8 +159,6 @@ const load_page = async (href: string, history: boolean): Promise<boolean> => {
tick().then(() => {
current_page_store.set(current_page)
})
return true
}
const click = (e: MouseEvent) => {
@@ -164,24 +181,23 @@ const click = (e: MouseEvent) => {
}
// 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) {
if (origin.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 !== "") {
if (origin.pathname === window.location.pathname && origin.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) {
if (find_page(origin.pathname) === null) {
return
}
console.log("Caught link click to", url.pathname)
console.log("Caught link click to", origin.pathname)
// The event has to be cancelled before the first await, after that the
// browser has already committed to the navigation
@@ -202,7 +218,7 @@ const popstate = (e: PopStateEvent) => {
<svelte:window onpopstate={popstate}/>
{#if current_page !== null}
<current_page.component />
<Page />
{#if current_page.footer !== false}
<Footer/>

View File

@@ -5,7 +5,7 @@ export type Tab = {
path: string,
prefix?: string,
title: string,
component?: Component,
component?: () => Promise<{ default: Component }>,
footer?: boolean,
login?: boolean,
custom_breadcrumbs?: boolean,