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:
@@ -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"),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -237,7 +237,4 @@ onMount(() => {
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.icon.button_highlight {
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -178,7 +178,7 @@ onMount(() => {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger_color, #c0392b);
|
||||
color: var(--red_color);
|
||||
}
|
||||
.trash_table {
|
||||
width: 100%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -186,6 +186,6 @@ onMount(() => {
|
||||
margin: 4px;
|
||||
}
|
||||
.playing {
|
||||
color: var(--highlight_color);
|
||||
color: var(--accent_color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
14
svelte/src/global.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,6 +84,6 @@ onMount(async () => {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.red {
|
||||
background-color: rgba(255, 0, 0, 0.2);
|
||||
background-color: var(--red_tint);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -125,7 +125,4 @@ header, .submenu {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.button_highlight {
|
||||
background: var(--body_background);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user