Compare commits

...

11 Commits

49 changed files with 502 additions and 456 deletions

BIN
res/static/img/header.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

BIN
res/static/img/nova.xcf Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

BIN
res/static/img/nova_128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
res/static/img/nova_256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
res/static/img/nova_32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
res/static/img/nova_512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
res/static/img/nova_64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

View File

@@ -393,10 +393,6 @@ table:not(.form) {
min-width: 100%;
}
tr {
border-bottom: 1px var(--separator) solid;
}
tr>td {
padding: 0.2em 0.5em;
}
@@ -459,8 +455,8 @@ input[type="color"] {
border-radius: 6px;
margin: 2px;
background: var(--input_background);
gap: 3px;
padding: 3px;
gap: 2px;
padding: 2px;
overflow: hidden;
color: var(--input_text);
cursor: pointer;
@@ -501,7 +497,7 @@ input[type="color"]:focus {
color: var(--input_text);
text-decoration: none;
background: var(--input_hover_background);
box-shadow: 0px 0px 0px 1px var(--highlight_color);
box-shadow: 0px 0px 0px 1px var(--highlight_color) !important;
}
button:active,
@@ -510,13 +506,12 @@ input[type="submit"]:active,
input[type="button"]:active,
input[type="color"]:active {
box-shadow: inset 4px 4px 6px var(--shadow_color);
/* Exactly 3px offset compared to the inactive padding to give a depth effect */
padding: 6px 0px 0px 6px;
/* Exactly 2px offset compared to the inactive padding to give a depth effect */
padding: 4px 0px 0px 4px;
}
.button_highlight {
background: var(--highlight_background) !important;
color: var(--highlight_text_color) !important;
box-shadow: 0px 0px 0px 1px var(--highlight_color) !important;
}
.button_red {

View File

@@ -10,16 +10,17 @@
<link id="stylesheet_layout" rel="stylesheet" type="text/css" href="/res/style/layout.css?v{{cacheID}}"/>
<link id="stylesheet_theme" rel="stylesheet" type="text/css" href="/theme.css"/>
<link rel="icon" sizes="32x32" href="/res/img/pixeldrain_32.png" />
<link rel="icon" sizes="128x128" href="/res/img/pixeldrain_128.png" />
<link rel="icon" sizes="152x152" href="/res/img/pixeldrain_152.png" />
<link rel="icon" sizes="180x180" href="/res/img/pixeldrain_180.png" />
<link rel="icon" sizes="192x192" href="/res/img/pixeldrain_192.png" />
<link rel="icon" sizes="196x196" href="/res/img/pixeldrain_196.png" />
<link rel="icon" sizes="256x256" href="/res/img/pixeldrain_256.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/res/img/pixeldrain_152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/res/img/pixeldrain_180.png" />
<link rel="shortcut icon" sizes="196x196" href="/res/img/pixeldrain_196.png" />
<link rel="icon" sizes="32x32" href="/res/img/nova_32.png" />
<link rel="icon" sizes="64x64" href="/res/img/nova_64.png" />
<link rel="icon" sizes="128x128" href="/res/img/nova_128.png" />
<link rel="icon" sizes="152x152" href="/res/img/nova_152.png" />
<link rel="icon" sizes="180x180" href="/res/img/nova_180.png" />
<link rel="icon" sizes="192x192" href="/res/img/nova_192.png" />
<link rel="icon" sizes="196x196" href="/res/img/nova_196.png" />
<link rel="icon" sizes="256x256" href="/res/img/nova_256.png" />
<link rel="apple-touch-icon" sizes="128x128" href="/res/img/nova_128.png" />
<link rel="apple-touch-icon" sizes="256x256" href="/res/img/nova_256.png" />
<link rel="shortcut icon" sizes="256x256" href="/res/img/nova_256.png" />
{{ template "opengraph" .OGData }}
<script>

View File

@@ -162,7 +162,8 @@ onMount(() => {
<section>
<h3>Bandwidth usage and file views</h3>
</section>
<div class="highlight_border" style="margin-bottom: 6px;">
<div>
<button onclick={() => loadGraph(1440, 1, true)}>Day 1m</button>
<button onclick={() => loadGraph(10080, 10, true)}>Week 10m</button>
<button onclick={() => loadGraph(43200, 60, true)}>Month 1h</button>
@@ -172,9 +173,11 @@ onMount(() => {
<button onclick={() => loadGraph(1051200, 1440, false)}>Two Years 1d</button>
<button onclick={() => loadGraph(2628000, 1440, false)}>Five Years 1d</button>
</div>
<Chart bind:this={graphEgress} data_type="bytes" />
<Chart bind:this={graphDownloads} data_type="number" />
<div class="highlight_border">
<div>
Total usage from {start_time} to {end_time}<br/>
{formatDataVolume(total_egress, 3)} egress,
{formatThousands(total_downloads)} downloads

View File

@@ -9,30 +9,32 @@ import { loading_finish, loading_start } from "lib/Loading";
const groups: {
title: string,
expanded: boolean
graphs: {
metric: string,
agg_base?: string,
agg_divisor?: string,
data_type: string,
}[],
}[] = [
}[] = $state([
{
title: "API",
title: "API", expanded: false,
graphs: [
{metric: "api_request", data_type: "number"},
{metric: "api_request_duration", data_type: "duration"},
{metric: "api_request_duration_avg", agg_base: "api_request_duration", agg_divisor: "api_request", data_type: "duration"},
{metric: "api_error", data_type: "number"},
{metric: "api_error_400", data_type: "number"},
{metric: "api_error_500", data_type: "number"},
{metric: "api_panic", data_type: "number"},
],
}, {
title: "Task scheduler",
title: "Task scheduler", expanded: false,
graphs: [
{metric: "scheduler_filesystem_remove_orphan", data_type: "number"},
{metric: "scheduler_timeseries_delete", data_type: "number"},
],
}, {
title: "Database",
title: "Database", expanded: false,
graphs: [
{metric: "database_query", data_type: "number"},
{metric: "database_query_duration", data_type: "duration"},
@@ -43,7 +45,7 @@ const groups: {
{metric: "database_query_error", data_type: "number"},
],
}, {
title: "Pixelstore",
title: "Pixelstore", expanded: false,
graphs: [
{metric: "pixelstore_write", data_type: "number"},
{metric: "pixelstore_write_size", data_type: "bytes"},
@@ -55,7 +57,7 @@ const groups: {
{metric: "pixelstore_peer_up", data_type: "number"},
],
}, {
title: "Pixelstore reads",
title: "Pixelstore reads", expanded: false,
graphs: [
{metric: "pixelstore_cache_read", data_type: "number"},
{metric: "pixelstore_cache_read_size", data_type: "bytes"},
@@ -67,7 +69,7 @@ const groups: {
{metric: "pixelstore_read_retry_error", data_type: "number"},
],
}, {
title: "Pixelstore shards",
title: "Pixelstore shards", expanded: false,
graphs: [
{metric: "pixelstore_shard_delete", data_type: "number"},
{metric: "pixelstore_shard_delete_size", data_type: "bytes"},
@@ -77,7 +79,7 @@ const groups: {
{metric: "pixelstore_shard_move_size", data_type: "bytes"},
],
}, {
title: "Pixelstore API",
title: "Pixelstore API", expanded: false,
graphs: [
{metric: "pixelstore_api_error_400", data_type: "number"},
{metric: "pixelstore_api_error_500", data_type: "number"},
@@ -88,7 +90,7 @@ const groups: {
{metric: "pixelstore_api_status", data_type: "number"},
],
},
]
])
let dataWindow: number = $state(60)
let dataInterval: number = $state(1)
@@ -106,12 +108,17 @@ const load_metrics = async (window: number, interval: number) => {
let metrics_list: string[] = []
for (const group of groups) {
if (group.expanded === true) {
for (const graph of group.graphs) {
if (graph.metric !== undefined) {
metrics_list.push(graph.metric)
}
}
}
}
if (metrics_list.length === 0) {
return
}
loading_start()
try {
@@ -130,6 +137,9 @@ const load_metrics = async (window: number, interval: number) => {
// If the dataset uses the duration type, we need to convert the values
// to milliseconds
for (const group of groups) {
if (group.expanded === false) {
continue
}
for (const graph of group.graphs) {
if (graph.data_type === "duration" && metrics.metrics[graph.metric] !== undefined) {
for (const host of Object.keys(metrics.metrics[graph.metric])) {
@@ -198,7 +208,7 @@ onDestroy(() => {
</div>
{#each groups as group (group.title)}
<Expandable click_expand expanded>
<Expandable click_expand bind:expanded={group.expanded} on_expand={(expanded) => load_metrics(dataWindow, dataInterval)}>
{#snippet header()}
<div class="title">{group.title}</div>
{/snippet}

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { preventDefault } from 'svelte/legacy';
import { fs_encode_path } from "lib/FilesystemAPI.svelte";
import type { FSNavigator } from "./FSNavigator";
@@ -10,11 +9,7 @@ let { nav }: {
<div class="breadcrumbs">
{#each $nav.path as node, i (node.path)}
<a
href={"/d"+fs_encode_path(node.path)}
class="breadcrumb button flat"
onclick={preventDefault(() => {nav.navigate(node.path, true)})}
>
<a href={"/d"+fs_encode_path(node.path)} class="breadcrumb button flat">
{#if node.abuse_type !== undefined}
<i class="icon small">block</i>
{:else if node.is_shared()}

View File

@@ -53,12 +53,19 @@ export class FSNavigator {
}
}
last_requested_path: string = ""
navigate = async (path: string, push_history: boolean) => {
if (path === this.last_requested_path) {
console.debug("FSNavigator: Requested path is equal to current path. Debouncing")
return
}
this.last_requested_path = path
if (path[0] !== "/") {
path = "/" + path
}
console.debug("Navigating to path", path, push_history)
console.debug("FSNavigator: Navigating to path", path, push_history)
try {
loading_start()
@@ -97,7 +104,7 @@ export class FSNavigator {
// we still replace the URL with replaceState. This way the user is not
// greeted to a 404 page when refreshing after renaming a file
if (this.history_enabled) {
window.document.title = node.path[node.base_index].name + " / FNX"
window.document.title = node.path[node.base_index].name + " / Nova"
const url = "/d" + fs_encode_path(node.path[node.base_index].path) + window.location.hash
if (push_history) {
window.history.pushState({}, window.document.title, url)
@@ -273,3 +280,5 @@ const sort_children = (children: FSNode[], field: string, asc: boolean) => {
}
})
}
export let global_navigator = new FSNavigator(true)

View File

@@ -7,7 +7,7 @@ import DetailsWindow from "./DetailsWindow.svelte";
import FilePreview from "./viewers/FilePreview.svelte";
import FSUploadWidget from "./upload_widget/FSUploadWidget.svelte";
import { type FSPath } from "lib/FilesystemAPI.svelte";
import { FSNavigator } from "./FSNavigator"
import { global_navigator } from "./FSNavigator"
import { css_from_path } from "filesystem/edit_window/Branding";
import AffiliatePrompt from "user_home/AffiliatePrompt.svelte";
import { current_page_store } from "wrap/RouterStore";
@@ -20,7 +20,7 @@ let edit_window: EditWindow = $state()
let edit_visible = $state(false)
let details_window: DetailsWindow = $state()
const nav = $state(new FSNavigator(true))
const nav = global_navigator
onMount(() => {
if ((window as any).intial_node !== undefined) {
@@ -28,12 +28,16 @@ onMount(() => {
nav.open_node((window as any).initial_node as FSPath, false)
} else {
console.debug("No initial node, fetching path", window.location.pathname)
nav.navigate(decodeURI(window.location.pathname).replace(/^\/d/, ""), false)
nav.navigate(decodeURIComponent(window.location.pathname).replace(/^\/d/, ""), false)
}
// There is a global natigation handler which captures link clicks and loads
// the right svelte components. When a filesystem link is clicked this store
// is updated. Catch it and use the filesystem navigator to navigate to the
// right file
const page_sub = current_page_store.subscribe(() => {
console.debug("Caught page transition to", window.location.pathname)
nav.navigate(decodeURI(window.location.pathname).replace(/^\/d/, ""), false)
console.debug("Caught page transition to", window.location.pathname, "calling navigator")
nav.navigate(decodeURIComponent(window.location.pathname).replace(/^\/d/, ""), false)
})
// Subscribe to navigation updates. This function returns a deconstructor
@@ -43,13 +47,16 @@ onMount(() => {
return
}
// Custom CSS rules for the whole viewer
document.documentElement.style = css_from_path(nav.path)
// Custom CSS rules for the whole viewer. The MainMenu applies its
// styles to the <html> element. So we apply to the <body> element, our
// 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)
})
return () => {
page_sub()
nav_sub()
document.documentElement.style = ""
document.body.style = ""
}
})

View File

@@ -113,9 +113,9 @@ const add_styles = (style: Style, properties: FSNodeProperties) => {
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
// threshold is 40 instead of 50 because overall dark text is more legible
if (hsl[2] < 40) {
// If the lightness is less than 30 it is considered a dark colour. This
// threshold is 30 instead of 50 because overall dark text is more legible
if (hsl[2] < 30) {
hsl[2] = hsl[2] + amt // Dark color, add lightness
} else {
hsl[2] = hsl[2] - amt // Light color, remove lightness

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { fs_rename, fs_update, type FSNode, type NodeOptions } from "lib/FilesystemAPI.svelte";
import { fs_rename, fs_update, type FSNode, type FSPermissions, type NodeOptions } from "lib/FilesystemAPI.svelte";
import Modal from "util/Modal.svelte";
import BrandingOptions from "./BrandingOptions.svelte";
import { branding_from_props } from "./Branding";
@@ -22,6 +22,10 @@ let {
visible: boolean;
} = $props();
const default_permissions: FSPermissions = {
owner: false, read: false, write: false, delete: false
}
// Open the edit window. Argument 1 is the file to edit, 2 is whether the file
// should be opened after the user finishes editing and 3 is the default tab
// that should be open when the window shows
@@ -45,9 +49,9 @@ export const edit = (f: FSNode, oae = false, open_tab = "") => {
}
options.custom_domain_name = file.custom_domain_name
options.link_permissions = file.link_permissions
options.user_permissions = file.user_permissions
options.password_permissions = file.password_permissions
options.link_permissions = file.link_permissions === undefined ? default_permissions : file.link_permissions
options.user_permissions = file.user_permissions === undefined ? {} : file.user_permissions
options.password_permissions = file.password_permissions === undefined ? {} : file.password_permissions
branding_enabled = options.branding_enabled === "true"
if (branding_enabled) {

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import Button from "layout/Button.svelte";
import { fs_delete_all, type FSNode } from "lib/FilesystemAPI.svelte";
import PathLink from "filesystem/util/PathLink.svelte";
import type { FSNavigator } from "filesystem/FSNavigator";
import { loading_finish, loading_start } from "lib/Loading";
@@ -48,10 +47,8 @@ const delete_file = async (e: MouseEvent) => {
<legend>File settings</legend>
{#if is_root_dir}
<div class="highlight_yellow">
Filesystem root cannot be renamed. If this shared directory
is in
<PathLink nav={nav} path="/me">your filesystem</PathLink>
you can rename it from there
Filesystem root cannot be renamed. If this shared directory is in <a
href="/d/me">your filesystem</a> you can rename it from there
</div>
{/if}
<div class="form_grid">

View File

@@ -69,7 +69,7 @@ const file_event: FileActionHandler = (action: FileAction, index: number, orig:
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
select_node(index)
} else {
file_menu.open(nav.children[index], orig.target)
file_menu.open(nav.children[index], orig.target, orig)
}
break
case FileAction.Edit:
@@ -86,7 +86,7 @@ const file_event: FileActionHandler = (action: FileAction, index: number, orig:
select_node(index)
break
case FileAction.Menu:
file_menu.open(nav.children[index], orig.target)
file_menu.open(nav.children[index], orig.target, orig)
break
}
}
@@ -387,15 +387,7 @@ run(() => {
{#if creating_dir}
<CreateDirectory nav={nav} />
{/if}
{#if $nav.base.path === "/me"}
<div class="highlight_shaded" style="background-color: rgba(255, 255, 0, 0.05); border-radius: 0;">
The filesystem is experimental!
<a href="/filesystem">Please read the guide</a>
</div>
{/if}
</div>
{#if $nav.base.abuse_type !== undefined}
<div class="highlight_red">

View File

@@ -19,16 +19,23 @@ let {
let dialog: Dialog = $state()
let node: FSNode = $state(null)
export const open = async (n: FSNode, target: EventTarget) => {
export const open = async (n: FSNode, target: EventTarget, event: Event) => {
node = n
let el: HTMLElement = (target as Element).closest("button")
if (el === null) {
el = (target as Element).closest("a")
}
// Wait for the view to update, so the dialog gets the proper measurements
await tick()
let el: HTMLElement = (target as Element).closest("button")
if (el !== null) {
dialog.open(el.getBoundingClientRect())
return
}
if (event instanceof MouseEvent) {
dialog.open(event)
return
}
console.error("Cannot find suitable target for spawning dialog window")
}
const delete_node = async () => {

View File

@@ -112,7 +112,6 @@ td {
color: var(--highlight_text_color);
}
td {
padding: 2px;
vertical-align: middle;
}
.node_icon {

View File

@@ -176,9 +176,13 @@ const leave_confirmation = (e: BeforeUnloadEvent) => {
{/if}
</div>
<div class="body">
{#each upload_queue as job}
{#each upload_queue as job, i}
{#if job.status !== "finished"}
<UploadProgress bind:this={job.component} job={job} finish={finish_upload}/>
<UploadProgress
bind:this={upload_queue[i].component}
bind:job={upload_queue[i]}
finish={finish_upload}
/>
{/if}
{/each}
</div>

View File

@@ -32,7 +32,7 @@ export const upload_file = (
return
}
console.log("Uploading file to ", fs_path_url(path))
console.debug("Uploading file to", fs_path_url(path))
let xhr = new XMLHttpRequest();
xhr.open("PUT", fs_path_url(path) + "?make_parents=true", true);

View File

@@ -1,18 +0,0 @@
<script lang="ts">
import { preventDefault } from 'svelte/legacy';
import type { FSNavigator } from "filesystem/FSNavigator";
let {
nav,
path = "",
children
}: {
nav: FSNavigator;
path?: string;
children?: import('svelte').Snippet;
} = $props();
</script>
<a href={"/d"+path} onclick={preventDefault(() => {nav.navigate(path, true)})}>
{@render children?.()}
</a>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { preventDefault } from 'svelte/legacy';
import { onMount } from 'svelte'
import { fs_path_url, fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte"
import { fs_path_url, fs_encode_path, fs_node_icon, FSNode } from "lib/FilesystemAPI.svelte"
import TextBlock from "layout/TextBlock.svelte"
import type { FSNavigator } from 'filesystem/FSNavigator';
@@ -13,7 +12,7 @@ let { nav, children }: {
let player: HTMLAudioElement = $state()
let playing = $state(false)
let media_session = false
let siblings = $state([])
let siblings: FSNode[] = $state([])
export const toggle_playback = () => playing ? player.pause() : player.play()
export const toggle_mute = () => player.muted = !player.muted
@@ -28,6 +27,7 @@ export const seek = (delta: number) => {
}
}
var background_div: HTMLDivElement
export const update = async () => {
if (media_session) {
navigator.mediaSession.metadata = new MediaMetadata({
@@ -38,6 +38,15 @@ export const update = async () => {
}
siblings = await nav.get_siblings()
for(const sib of siblings) {
const lower = sib.name.toLowerCase()
if (lower === "cover.jpg" || lower === "cover.png" || lower === "cover.webp") {
console.debug("Found album cover image", sib)
background_div.style.backgroundImage = `url("/api/filesystem/${sib.path}")`
break
}
}
}
onMount(() => {
@@ -54,7 +63,8 @@ onMount(() => {
{@render children?.()}
<TextBlock width="1000px">
<div bind:this={background_div} class="background_div">
<TextBlock width="1000px">
<audio
bind:this={player}
class="player"
@@ -82,11 +92,7 @@ onMount(() => {
<h2>Tracklist</h2>
{#each siblings as sibling (sibling.path)}
<a
href={"/d"+fs_encode_path(sibling.path)}
onclick={preventDefault(() => nav.navigate(sibling.path, true))}
class="node"
>
<a href={"/d"+fs_encode_path(sibling.path)} class="node">
{#if sibling.path === $nav.base.path}
<i class="play_arrow icon">play_arrow</i>
{:else}
@@ -96,9 +102,16 @@ onMount(() => {
<br/>
</a>
{/each}
</TextBlock>
</TextBlock>
</div>
<style>
.background_div {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
min-height: 100%;
}
.player {
width: 100%;
}

View File

@@ -22,7 +22,7 @@ let upload_widget
<div class="page_content">
<section>
<p>
FNX.storage is a platform for cost-effective cloud storage and
Nova.storage is a platform for cost-effective cloud storage and
content delivery. We will store and serve your files at an extremely
competitive rate.
</p>
@@ -36,7 +36,7 @@ let upload_widget
<div>€ 1 / TB</div>
</div>
</div>
<h2>What FNX is good at</h2>
<h2>What Nova is good at</h2>
<ul>
<li>
Serving large files to millions of people worldwide
@@ -147,11 +147,11 @@ header > h1 {
.header_image_container {
text-align: initial;
margin: auto;
margin-bottom: 1.5em; /*Offset for menu button*/
height: 150px;
margin-bottom: 50px;
height: 100px;
width: 500px;
max-width: 100%;
background-image: url("/res/img/header_orbitron.webp");
background-image: url("/res/img/header.webp");
background-repeat: no-repeat;
background-size: contain;
background-position: center;

View File

@@ -18,7 +18,7 @@ let egress = $state(10) // TB
let avg_file_size = $state(1000) // kB
$effect(() => {
// FNX has a minimum file size of 10kB. Calculate the number of files from
// Nova has a minimum file size of 10kB. Calculate the number of files from
// storage and avg file size, then calculate storage usage based on that.
fnx_storage = Math.max(storage * 4, ((storage*10)/avg_file_size)*4)
fnx_egress = egress * 1
@@ -67,14 +67,14 @@ $effect(() => {
<div class="bars">
<div>
<div>
FNX.storage - <Euro amount={fnx_total*1e6}/> / month<br/>
Nova.storage - <Euro amount={fnx_total*1e6}/> / month<br/>
<Euro amount={fnx_storage*1e6}/> storage,
<Euro amount={fnx_egress*1e6}/> egress
<ProgressBar used={fnx_total} total={price_max}/>
</div>
{#if avg_file_size < 10}
<div>
FNX counts a minimum file size of 10 kB. Files smaller than that
Nova counts a minimum file size of 10 kB. Files smaller than that
are rounded up to 10 kB.
</div>
{/if}
@@ -105,16 +105,16 @@ $effect(() => {
</div>
<p>
Note that while FNX.storage might not seem to be the cheapest option in some
Note that while Nova.storage might not seem to be the cheapest option in some
cases, most cloud providers have extra hidden costs for API calls and
region-specific prices. This makes it very hard to accurately compare the
pricing of these platforms. FNX.storage includes no hidden costs, I only
pricing of these platforms. Nova.storage includes no hidden costs, I only
charge for storage and egress.
</p>
<p>
Large cloud providers like Amazon, Microsoft and Google are excluded from
this calculation because their pricing is too complex accurately compare
them. Just assume that FNX wil be cheaper.
them. Just assume that Nova wil be cheaper.
</p>
<style>

View File

@@ -4,7 +4,7 @@ let { children }: {
} = $props();
let dialog: HTMLDialogElement = $state()
export const open = (button_rect: DOMRect) => {
export const open = (origin: DOMRect|MouseEvent) => {
// Show the window so we can get the location
dialog.showModal()
@@ -15,10 +15,18 @@ export const open = (button_rect: DOMRect) => {
const max_left = window.innerWidth - dialog_rect.width - edge_offset
const max_top = window.innerHeight - dialog_rect.height - edge_offset
let min_left: number, min_top: number
if (origin instanceof DOMRect) {
// Position the dialog in horizontally in the center of the button and
// verticially below it
const min_left = Math.max((button_rect.left + (button_rect.width/2)) - (dialog_rect.width/2), edge_offset)
const min_top = Math.max(button_rect.bottom, edge_offset)
min_left = Math.max((origin.left + (origin.width/2)) - (dialog_rect.width/2), edge_offset)
min_top = Math.max(origin.bottom, edge_offset)
} else if (origin instanceof MouseEvent) {
// Place the dialog at the bottom right of the mouse pointer, like
// regular context menus
min_left = Math.max(origin.clientX, edge_offset)
min_top = Math.max(origin.clientY, edge_offset)
}
// Place the window
dialog.style.left = Math.round(Math.min(min_left, max_left)) + "px"
@@ -35,6 +43,7 @@ export const close = () => {
// the dialog itself then the click was on the dialog background
const click = (e: MouseEvent) => {
if (e.target === dialog) {
e.preventDefault()
dialog.close()
}
}
@@ -42,7 +51,7 @@ const click = (e: MouseEvent) => {
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<dialog bind:this={dialog} onclick={click}>
<dialog bind:this={dialog} onclick={click} oncontextmenu={click}>
{@render children?.()}
</dialog>

View File

@@ -6,6 +6,7 @@ let {
group_first = false,
group_middle = false,
group_last = false,
highlight = true,
action,
children,
}: {
@@ -15,6 +16,7 @@ let {
group_first?: boolean;
group_middle?: boolean;
group_last?: boolean;
highlight?: boolean;
action?: (e: MouseEvent) => void;
children?: import('svelte').Snippet;
} = $props();
@@ -31,7 +33,7 @@ const click = (e: MouseEvent) => {
onclick={click}
type="button"
class="button"
class:button_highlight={on}
class:button_highlight={on && highlight}
class:group_first
class:group_middle
class:group_last

View File

@@ -59,6 +59,10 @@ export class FSNode {
(this.password_permissions !== undefined && Object.keys(this.password_permissions).length > 0)
}
is_hidden = (): boolean => {
return this.name.startsWith(".")
}
download = () => {
const a = document.createElement("a")

View File

@@ -1,8 +1,14 @@
import { current_page_store, type Tab } from "wrap/RouterStore"
import { global_navigator } from "filesystem/FSNavigator"
import { current_page_store } from "wrap/RouterStore"
export const highlight_current_page = (node: HTMLAnchorElement) => {
const set_highlight = () => {
if (window.location.pathname === URL.parse(node.href).pathname) {
const currentpath = window.location.pathname + "/"
const hrefpath = URL.parse(node.href).pathname + "/"
if (
currentpath === hrefpath ||
(hrefpath !== "" && hrefpath !== "/" && currentpath.startsWith(hrefpath))
) {
node.classList.add("button_highlight")
} else {
node.classList.remove("button_highlight")
@@ -13,10 +19,12 @@ export const highlight_current_page = (node: HTMLAnchorElement) => {
set_highlight()
// Set up a listener with the page router to catch navigation events
const unsub = current_page_store.subscribe((page: Tab) => { set_highlight() })
const unsub = current_page_store.subscribe(() => { set_highlight() })
const unsub2 = global_navigator.subscribe(() => { set_highlight() })
return {
destroy() {
unsub()
unsub2()
}
}
}

View File

@@ -88,7 +88,8 @@ const login = async (e?: SubmitEvent) => {
{
method: "POST",
body: fd,
credentials: "omit", // Dont send existing session cookies
// Allow server to set cookies
credentials: "include",
},
))
@@ -99,9 +100,6 @@ const login = async (e?: SubmitEvent) => {
}
}
// Save the session cookie
document.cookie = "pd_auth_key="+resp.auth_key+"; Max-Age=31536000;"
dispatch("login", {key: resp.auth_key})
if (typeof login_redirect === "string" && login_redirect.startsWith("/")) {

View File

@@ -2,6 +2,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';
let loaded = $state(false)
let rows = $state([])
@@ -130,12 +131,15 @@ const logout = async (key) => {
<tbody>
{#each rows as row (row.auth_key)}
<tr style="border-bottom: none;">
<td>{row.auth_key}</td>
<td>
<CopyButton text={row.auth_key} small_icon>Copy</CopyButton>
{row.auth_key}
</td>
<td>{formatDate(row.creation_time, true, true, false)}</td>
<td>{formatDate(row.last_used_time, true, true, false)}</td>
<td>{row.creation_ip_address}</td>
<td>
<button onclick={preventDefault(() => {logout(row.auth_key)})} class="button button_red round">
<button onclick={(e) => {e.preventDefault();logout(row.auth_key)}} class="button button_red round">
<i class="icon">delete</i>
</button>
</td>

View File

@@ -3,6 +3,7 @@ import { get_endpoint, get_user, type User } from "lib/PixeldrainAPI";
import { onMount } from "svelte";
import HotlinkProgressBar from "user_home/HotlinkProgressBar.svelte";
import StorageProgressBar from "user_home/StorageProgressBar.svelte";
import { formatThousands } from "util/Formatting";
let transfer_cap = $state(0)
let transfer_used = $state(0)
@@ -53,6 +54,9 @@ onMount(async () => {
<StorageProgressBar used={user.storage_space_used} total={storage_limit}/>
<br/>
File count: {formatThousands(user.filesystem_node_count)}
<br/><br/>
Egress used (30 days):
(<a href="/user/sharing/bandwidth">set custom limit</a>)
<HotlinkProgressBar used={transfer_used} total={transfer_cap}></HotlinkProgressBar>

View File

@@ -185,7 +185,7 @@ onMount(() => {
max-width: 100%;
background: var(--body_background);
border-radius: 8px;
padding: 2px;
border: 1px solid var(--separator);
text-align: initial;
}
.card_component {

View File

@@ -1,212 +0,0 @@
<script>
import { preventDefault } from 'svelte/legacy';
import UploadProgressBar from "home_page/UploadProgressBar.svelte"
import { tick } from "svelte"
import UploadStats from "home_page/UploadStats.svelte";
export const pick_files = () => {
file_input_field.click()
}
// === UPLOAD LOGIC ===
let file_input_field = $state()
const file_input_change = (event) => {
// Start uploading the files async
upload_files(event.target.files)
// This resets the file input field
file_input_field.nodeValue = ""
}
const paste = (e) => {
if (e.clipboardData.files[0]) {
e.preventDefault();
e.stopPropagation();
upload_files(e.clipboardData.files)
}
}
let active_uploads = 0
let upload_queue = $state([])
let status = $state("idle") // idle, uploading, finished
let upload_stats = $state()
export const upload_files = async (files) => {
if (files.length === 0) {
return
}
// Add files to the queue
for (let i = 0; i < files.length; i++) {
if (files[i].type === "" && files[i].size === 0) {
continue
}
upload_queue.push({
file: files[i],
name: files[i].name,
status: "queued",
component: null,
id: "",
total_size: files[i].size,
loaded_size: 0,
on_finished: finish_upload,
})
}
// Reassign array and wait for tick to complete. After the tick is completed
// each upload progress bar will have bound itself to its array item
upload_queue = upload_queue
await tick()
start_upload()
}
const start_upload = () => {
let finished_count = 0
for (let i = 0; i < upload_queue.length && active_uploads < 3; i++) {
if (upload_queue[i].status == "queued") {
active_uploads++
upload_queue[i].component.start()
} else if (
upload_queue[i].status == "finished" ||
upload_queue[i].status == "error"
) {
finished_count++
}
}
if (active_uploads === 0 && finished_count != 0) {
status = "finished"
upload_stats.finish()
} else {
status = "uploading"
upload_stats.start()
}
}
const finish_upload = (file) => {
active_uploads--
start_upload()
}
const leave_confirmation = e => {
if (status === "uploading") {
e.preventDefault()
e.returnValue = "If you close the page your files will stop uploading. Do you want to continue?"
return e.returnValue
} else {
return null
}
}
// === SHARING BUTTONS ===
let share_link = ""
let input_album_name = $state("")
let btn_create_list
const create_list = async (title, anonymous) => {
let files = upload_queue.reduce(
(acc, curr) => {
if (curr.status === "finished") {
acc.push({"id": curr.id})
}
return acc
},
[],
)
const resp = await fetch(
window.api_endpoint+"/list",
{
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify({
"title": title,
"anonymous": anonymous,
"files": files
})
}
)
if(!resp.ok) {
return Promise.reject("HTTP error: "+resp.status)
}
return await resp.json()
}
const share_mail = () => window.open("mailto:please@set.address?subject=File%20on%20pixeldrain&body=" + share_link)
const share_twitter = () => window.open("https://twitter.com/share?url=" + share_link)
const share_facebook = () => window.open('https://www.facebook.com/sharer.php?u=' + share_link)
const share_reddit = () => window.open('https://www.reddit.com/submit?url=' + share_link)
const share_tumblr = () => window.open('https://www.tumblr.com/share/link?url=' + share_link)
const create_album = () => {
if (!input_album_name) {
return
}
create_list(input_album_name, false).then(resp => {
window.location = '/l/' + resp.id
}).catch(err => {
alert("Failed to create list. Server says this:\n"+err)
})
}
const keydown = (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) {
return // prevent custom shortcuts from interfering with system shortcuts
}
if (document.activeElement.type && document.activeElement.type === "text") {
return // Prevent shortcuts from interfering with input fields
}
switch (e.key) {
case "u": file_input_field.click(); break
case "l": btn_create_list.click(); break
case "e": share_mail(); break
case "w": share_twitter(); break
case "f": share_facebook(); break
case "r": share_reddit(); break
case "m": share_tumblr(); break
}
}
</script>
<svelte:window onpaste={paste} onkeydown={keydown} onbeforeunload={leave_confirmation} />
<input bind:this={file_input_field} onchange={file_input_change} type="file" name="file" multiple="multiple" class="hide"/>
<UploadStats bind:this={upload_stats} upload_queue={upload_queue}/>
{#if upload_queue.length > 1}
<div class="album_widget">
Create an album<br/>
<form class="album_name_form" onsubmit={preventDefault(create_album)}>
<div>Name:</div>
<input bind:value={input_album_name} type="text" placeholder="My album"/>
<button type="submit" disabled={status !== "finished"}>
<i class="icon">create_new_folder</i> Create
</button>
</form>
</div>
{/if}
{#each upload_queue as file}
<UploadProgressBar bind:this={file.component} job={file}></UploadProgressBar>
{/each}
<style>
.album_widget {
display: block;
border-bottom: 1px solid var(--separator);
}
.album_name_form {
display: inline-flex;
flex-direction: row;
align-items: center;
}
.hide {
display: none;
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { formatDataVolume, formatNumber } from "./Formatting";
import { formatDataVolume, formatDuration, formatNumber } from "./Formatting";
import { color_by_name } from "./Util";
import {
Chart,
@@ -33,12 +33,14 @@ let {
legend = true,
tooltips = true,
ticks = true,
animations = true,
height = "300px"
}: {
data_type?: string;
legend?: boolean;
tooltips?: boolean;
ticks?: boolean;
animations?: boolean;
height?: string;
} = $props();
@@ -85,6 +87,9 @@ onMount(() => {
},
tooltip: {
enabled: tooltips,
itemSort: (a, b): number => {
return <number>b.raw - <number>a.raw
},
},
},
layout: {
@@ -102,6 +107,8 @@ onMount(() => {
callback: function (value: number, index: number, values: Tick[]) {
if (data_type == "bytes") {
return formatDataVolume(value, 3);
} else if (data_type === "duration") {
return formatDuration(value, 2);
}
return formatNumber(value, 3);
},
@@ -129,6 +136,11 @@ onMount(() => {
}
}
);
if (!animations) {
chart_object.options.animation = false
chart_object.options.transitions.active.animation.duration = 0
}
})
</script>

View File

@@ -8,6 +8,9 @@ export const toggle = () => {
const header_click = () => {
if (click_expand) {
toggle()
if (on_expand !== null) {
on_expand(expanded)
}
}
}
@@ -22,6 +25,7 @@ const keypress = e => {
let {
expanded = $bindable(false),
click_expand = false,
on_expand = null,
highlight = false,
header,
children
@@ -33,6 +37,10 @@ let {
// stopPropagation if you want to use other interactive elements in the
// title bar
click_expand?: boolean;
// Callback for when the user expands the view
on_expand?: (expanded: boolean) => void;
// Highlight the title bar if the user moves their mouse over it
highlight?: boolean;
header?: import('svelte').Snippet;

View File

@@ -58,11 +58,24 @@ const hour = minute * 60
const day = hour * 24
export const formatDuration = (ms: number, decimals: number) => {
let remainingDecimals = decimals
let res = ""
if (ms >= day) { res += Math.floor(ms / day) + "d " }
if (ms >= hour) { res += Math.floor((ms % day) / hour) + "h " }
if (ms >= minute) { res += Math.floor((ms % hour) / minute) + "m " }
return res + ((ms % minute) / second).toFixed(decimals) + "s"
if (ms >= day && remainingDecimals > 0) {
res += Math.floor(ms / day) + "d "
remainingDecimals--
}
if (ms >= hour && remainingDecimals > 0) {
res += Math.floor((ms % day) / hour) + "h "
remainingDecimals--
}
if (ms >= minute && remainingDecimals > 0) {
res += Math.floor((ms % hour) / minute) + "m "
remainingDecimals--
}
if (remainingDecimals > 0) {
res += ((ms % minute) / second).toFixed(remainingDecimals) + "s"
}
return res
}
export const formatDate = (

View File

@@ -135,6 +135,7 @@ these padding divs to move it 25% up */
border-radius: 18px 18px 8px 8px;
overflow: hidden;
text-align: left;
border: 1px solid var(--separator);
}
.header {
flex-grow: 0;

View File

@@ -49,7 +49,7 @@ const get_page = () => {
}
title = current_subpage === null ? current_page.title : current_subpage.title
window.document.title = title+" / FNX"
window.document.title = title+" / Nova"
}
let current_page: Tab = $state(null)

View File

@@ -2,6 +2,7 @@
import { bookmark_del, bookmarks_store } from "lib/Bookmarks";
import { fs_encode_path } from "lib/FilesystemAPI.svelte";
import { highlight_current_page } from "lib/HighlightCurrentPage";
import MenuEntry from "./MenuEntry.svelte";
let { menu_collapsed }: { menu_collapsed: boolean } = $props();
@@ -13,15 +14,16 @@ const toggle_edit = () => {
</script>
{#if $bookmarks_store.length !== 0}
<div class="title">
<div class:hide={menu_collapsed}>Bookmarks</div>
<MenuEntry id="bookmarks" collapsed={menu_collapsed}>
{#snippet title()}
<div class="title">Bookmarks</div>
<button onclick={() => toggle_edit()} class:button_highlight={editing}>
<i class="icon">edit</i>
</button>
</div>
{/if}
{/snippet}
{#each $bookmarks_store as bookmark}
{#snippet body()}
{#each $bookmarks_store as bookmark}
<div class="row">
<a class="button" href="/d{fs_encode_path(bookmark.path)}" use:highlight_current_page>
<i class="icon">{bookmark.icon}</i>
@@ -33,22 +35,16 @@ const toggle_edit = () => {
</button>
{/if}
</div>
{/each}
{/each}
{/snippet}
</MenuEntry>
{/if}
<style>
.title {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--separator);
}
.title > div {
flex: 1 1 auto;
text-align: center;
}
.title > button {
flex: 0 0 auto;
}
.row {
display: flex;
flex-direction: row;

View File

@@ -11,17 +11,19 @@ import { css_from_path } from "filesystem/edit_window/Branding";
import { loading_run, loading_store } from "lib/Loading";
import Spinner from "util/Spinner.svelte";
import { get_user } from "lib/PixeldrainAPI";
import Tree from "./Tree.svelte";
import MenuEntry from "./MenuEntry.svelte";
let menu_collapsed = false
let menu_collapsed: boolean = $state(false)
const toggle_menu = (e: MouseEvent) => {
menu_collapsed = !menu_collapsed
}
onMount(async () => {
onMount(() => {
menu_collapsed = document.documentElement.clientWidth < 1000
await loading_run(async () => {
loading_run(async () => {
const user = await get_user()
if (user.username === undefined || user.username === "") {
return
@@ -34,7 +36,7 @@ onMount(async () => {
<div class="nav_container">
<div class="scroll_container">
<nav class="nav">
<nav class="nav" class:collapse={menu_collapsed}>
<button class="button" onclick={toggle_menu}>
<i class="icon">menu</i>
<span class:hide={menu_collapsed}>Collapse menu</span>
@@ -48,12 +50,12 @@ onMount(async () => {
{#if $user.username !== undefined && $user.username !== ""}
<div class="separator" class:hide={menu_collapsed}></div>
<div class="username" class:hide={menu_collapsed}>
{$user.username}
</div>
<div class="separator"></div>
<MenuEntry id="subscription_info" collapsed={menu_collapsed}>
{#snippet title()}
<div class="username">{$user.username}</div>
{/snippet}
{#snippet body()}
<div class="stats_table" class:hide={menu_collapsed}>
<div>Subscription</div>
<div>{$user.subscription.name}</div>
@@ -68,8 +70,8 @@ onMount(async () => {
<div>Transfer used</div>
<div>{formatDataVolume($user.monthly_transfer_used, 3)}</div>
</div>
<div class="separator" class:hide={menu_collapsed}></div>
{/snippet}
</MenuEntry>
<a class="button" href="/d/me" use:highlight_current_page>
<i class="icon">folder</i>
@@ -110,6 +112,7 @@ onMount(async () => {
<div class="separator"></div>
<Bookmarks menu_collapsed={menu_collapsed}/>
<Tree menu_collapsed={menu_collapsed}/>
</nav>
</div>
</div>
@@ -154,8 +157,14 @@ onMount(async () => {
.nav {
display: flex;
flex-direction: column;
width: 15em;
min-width: 10em;
max-width: 15em;
}
.nav.collapse {
width: unset;
min-width: unset;
}
.nav > .button {
background: none;
box-shadow: none;
@@ -165,17 +174,17 @@ onMount(async () => {
overflow-x: hidden;
max-width: 100%;
}
.username {
flex: 1 1 auto;
text-align: center;
margin: 3px;
}
.separator {
height: 1px;
margin: 2px 0;
width: 100%;
background-color: var(--separator);
}
.username {
text-align: center;
margin: 3px;
}
.stats_table {
display: grid;
grid-template-columns: auto auto;

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import ToggleButton from "layout/ToggleButton.svelte";
import { onMount } from "svelte";
type Expandable = {[key: string]: boolean}
let {
id,
collapsed = false,
title,
body,
}: {
id: string
collapsed: boolean
title: import('svelte').Snippet
body: import('svelte').Snippet
} = $props();
let expanded: boolean = $state(true)
const get_status = (): Expandable => {
let exp = localStorage.getItem("menu_expanded")
if (exp === null) {
exp = "{}"
}
return JSON.parse(exp) as Expandable
}
onMount(() => {
let exp = get_status()
if (exp[id] !== undefined) {
expanded = exp[id]
}
})
const toggle = (e: MouseEvent) => {
let exp = get_status()
exp[id] = expanded
localStorage.setItem("menu_expanded", JSON.stringify(exp))
}
</script>
<div class="title">
<ToggleButton bind:on={expanded} action={toggle} icon_on="arrow_drop_down" icon_off="arrow_drop_up" highlight={false}/>
{#if !collapsed}
{@render title()}
{/if}
</div>
{#if expanded}
{@render body()}
<div class="separator"></div>
{/if}
<style>
.title {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--separator);
}
.separator {
height: 1px;
width: 100%;
background-color: var(--separator);
}
</style>

View File

@@ -37,7 +37,6 @@ let pages: Tab[] = [
title: "Filesystem",
component: Filesystem,
footer: false,
login: true,
}, {
path: "/admin",
prefix: "/admin/",
@@ -64,9 +63,13 @@ onMount(async () => {
let current_page: Tab = $state(null)
const load_page = (pathname: string, history: boolean): boolean => {
console.debug("Navigating to page", pathname, "log history:", history)
console.debug(
"Page router: Navigating to page", pathname,
"current path", window.location.pathname,
"log history:", history,
)
const path_decoded = decodeURI(pathname)
const path_decoded = decodeURIComponent(pathname)
let page_by_path: Tab = null
let page_by_prefix: Tab = null
for (const page of pages) {
@@ -98,7 +101,7 @@ const load_page = (pathname: string, history: boolean): boolean => {
return load_page("/login", true)
}
window.document.title = current_page.title+" / FNX"
window.document.title = current_page.title+" / Nova"
if(history) {
window.history.pushState({}, window.document.title, pathname)

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { global_navigator } from "filesystem/FSNavigator";
import { fs_encode_path, fs_node_icon, FSNode } from "lib/FilesystemAPI.svelte";
import { highlight_current_page } from "lib/HighlightCurrentPage";
import { onMount } from "svelte";
import MenuEntry from "./MenuEntry.svelte";
let { menu_collapsed }: { menu_collapsed: boolean } = $props();
let siblings: FSNode[] = $state([])
onMount(() => {
return global_navigator.subscribe(async () => {
siblings = await global_navigator.get_siblings()
})
})
</script>
<MenuEntry id="tree_parents" collapsed={menu_collapsed}>
{#snippet title()}
<div class="title">Parent directories</div>
<button title="Navigate up" onclick={() => global_navigator.navigate_up()}>
<i class="icon">north</i>
</button>
{/snippet}
{#snippet body()}
{#each $global_navigator.path.slice(0, $global_navigator.path.length-1) as node (node.id)}
{#if node.type === "dir"}
<div class="row">
<a class="button" href="/d{fs_encode_path(node.path)}" title="{node.name}">
<img class="thumbnail" src="{fs_node_icon(node, 32, 32)}" alt="{node.name}"/>
<span class:hide={menu_collapsed}>{node.name}</span>
</a>
</div>
{/if}
{/each}
{/snippet}
</MenuEntry>
<MenuEntry id="tree_siblings" collapsed={menu_collapsed}>
{#snippet title()}
<div class="title">Siblings</div>
<button title="Open previous sibling" onclick={() => global_navigator.open_sibling(-1)}>
<i class="icon">west</i>
</button>
<button title="Open next sibling" onclick={() => global_navigator.open_sibling(1)}>
<i class="icon">east</i>
</button>
{/snippet}
{#snippet body()}
{#each siblings as node (node.id)}
{#if !node.is_hidden()}
<div class="row">
<a class="button" href="/d{fs_encode_path(node.path)}" title="{node.name}" use:highlight_current_page>
<img class="thumbnail" src="{fs_node_icon(node, 32, 32)}" alt="{node.name}"/>
<span class:hide={menu_collapsed}>{node.name}</span>
</a>
</div>
{/if}
{/each}
{/snippet}
</MenuEntry>
<style>
.title {
flex: 1 1 auto;
text-align: center;
margin: 3px;
}
.row {
display: flex;
flex-direction: row;
}
.row>a {
flex: 1 1 auto;
}
.row>a>span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.button {
background: none;
box-shadow: none;
}
.thumbnail {
height: 1.5em;
width: 1.5em;
border-radius: 2px;
}
.hide {
display: none;
}
</style>

View File

@@ -21,6 +21,7 @@ export default defineConfig({
outDir: builddir,
emptyOutDir: true,
minify: production,
sourcemap: !production,
lib: {
entry: "src/wrap.js",
name: "fnx_web",