Compare commits

...

9 Commits

Author SHA1 Message Date
2fb0947077 Add black and white themes 2026-04-03 15:48:51 +02:00
373bf5c786 Fix filesystem error handler 2026-04-01 19:39:42 +02:00
f578f2c2c0 Move search bar into modal 2026-04-01 19:38:15 +02:00
dbfe9ff383 Limit size of siblings list and some bug fixes 2026-04-01 15:30:11 +02:00
8a2cdb4acd Add copy button for node ID 2026-04-01 15:28:01 +02:00
c0c61b07f0 Update dependencies 2026-04-01 15:26:36 +02:00
158ee0a206 Add sum and average to host metrics 2026-04-01 15:26:17 +02:00
0851d16cac Improve menu swipe detection 2026-02-26 14:47:23 +01:00
00b432f3dc Add context menu to breadcrumbs 2026-02-26 14:47:01 +01:00
35 changed files with 544 additions and 344 deletions

2
go.mod
View File

@@ -1,6 +1,6 @@
module fornaxian.tech/fnx_web module fornaxian.tech/fnx_web
go 1.25.1 go 1.26
replace ( replace (
fornaxian.tech/pixeldrain_api_client => ../pixeldrain_api_client fornaxian.tech/pixeldrain_api_client => ../pixeldrain_api_client

View File

@@ -135,8 +135,7 @@ pre>code {
} }
.page_content { .page_content {
background: var(--shaded_background); background: var(--body_background);
backdrop-filter: blur(4px);
text-align: center; text-align: center;
} }
@@ -198,12 +197,12 @@ section {
width: auto; width: auto;
height: auto; height: auto;
text-align: center; text-align: center;
padding: 4px; padding: 0.5em;
border-radius: 8px; border-radius: 8px;
} }
.highlight_border { .highlight_border {
border: 2px solid var(--separator); border: 1px var(--separator) solid;
} }
.highlight_shaded { .highlight_shaded {
@@ -292,7 +291,7 @@ hr {
height: 1px; height: 1px;
border: none; border: none;
background-color: var(--separator); background-color: var(--separator);
margin: 12px; margin: 0.5em 0;
} }
a { a {
@@ -315,7 +314,7 @@ ul {
fieldset { fieldset {
padding: 4px; padding: 4px;
border-radius: 8px; border-radius: 8px;
border: 2px var(--separator) solid; border: 1px var(--separator) solid;
margin: 0; margin: 0;
} }
@@ -325,7 +324,7 @@ fieldset>legend {
margin-right: auto; margin-right: auto;
border-radius: 8px; border-radius: 8px;
font-size: 1.1em; font-size: 1.1em;
border-bottom: 2px var(--separator) solid; border-bottom: 1px var(--separator) solid;
} }
/* Forms*/ /* Forms*/

View File

@@ -28,6 +28,7 @@
"@rollup/plugin-typescript": "^11.1.6", "@rollup/plugin-typescript": "^11.1.6",
"@types/jsmediatags": "^3.9.6", "@types/jsmediatags": "^3.9.6",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"baseline-browser-mapping": "^2.10.13",
"rollup": "^4.24.4", "rollup": "^4.24.4",
"rollup-plugin-livereload": "^2.0.5", "rollup-plugin-livereload": "^2.0.5",
"rollup-plugin-svelte": "^7.2.2", "rollup-plugin-svelte": "^7.2.2",
@@ -2701,13 +2702,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.16", "version": "2.10.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz",
"integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
} }
}, },
"node_modules/behave-js": { "node_modules/behave-js": {

View File

@@ -18,6 +18,7 @@
"@rollup/plugin-typescript": "^11.1.6", "@rollup/plugin-typescript": "^11.1.6",
"@types/jsmediatags": "^3.9.6", "@types/jsmediatags": "^3.9.6",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"baseline-browser-mapping": "^2.10.13",
"rollup": "^4.24.4", "rollup": "^4.24.4",
"rollup-plugin-livereload": "^2.0.5", "rollup-plugin-livereload": "^2.0.5",
"rollup-plugin-svelte": "^7.2.2", "rollup-plugin-svelte": "^7.2.2",

View File

@@ -158,13 +158,9 @@ const load_metrics = async (window: number, interval: number) => {
for (const host of Object.keys(metrics.metrics[graph.agg_base])) { for (const host of Object.keys(metrics.metrics[graph.agg_base])) {
metrics.metrics[graph.metric][host] = [] metrics.metrics[graph.metric][host] = []
for (let i = 0; i < metrics.metrics[graph.agg_base][host].length; i++) { for (let i = 0; i < metrics.metrics[graph.agg_base][host].length; i++) {
if (metrics.metrics[graph.agg_divisor][host][i] > 0) {
metrics.metrics[graph.metric][host].push( metrics.metrics[graph.metric][host].push(
metrics.metrics[graph.agg_base][host][i] / metrics.metrics[graph.agg_divisor][host][i] metrics.metrics[graph.agg_base][host][i] / Math.max(metrics.metrics[graph.agg_divisor][host][i], 1)
) )
} else {
metrics.metrics[graph.metric][host].push(0)
}
} }
} }
} }
@@ -204,7 +200,7 @@ onDestroy(() => {
<button onclick={() => setWindow(1051200, 1440)}>Two Years 1d</button> <button onclick={() => setWindow(1051200, 1440)}>Two Years 1d</button>
<button onclick={() => setWindow(2628000, 1440)}>Five Years 1d</button> <button onclick={() => setWindow(2628000, 1440)}>Five Years 1d</button>
<br/> <br/>
<ToggleButton bind:on={showAggregate}>Aggregate</ToggleButton> <ToggleButton bind:on={showAggregate}>Sum and Average</ToggleButton>
</div> </div>
{#each groups as group (group.title)} {#each groups as group (group.title)}
@@ -236,6 +232,6 @@ onDestroy(() => {
} }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
} }
</style> </style>

View File

@@ -28,19 +28,27 @@ const update_chart = async (timestamps: string[], metrics: {[key: string]: numbe
// there are in the response // there are in the response
chart.data().datasets.length = Object.keys(metrics).length chart.data().datasets.length = Object.keys(metrics).length
let i = 0
if (aggregate === true) { if (aggregate === true) {
i = 1 chart.data().datasets.length = 2
chart.data().datasets[0] = { chart.data().datasets[0] = {
label: "aggregate", label: "sum",
data: create_aggregate_dataset(metrics), data: create_sum_dataset(metrics),
borderWidth: 1, borderWidth: 1.5,
pointRadius: 0, pointRadius: 0,
borderColor: "#ffffff", borderColor: "#ff0000",
backgroundColor: "#ffffff", backgroundColor: "#ff0000",
} }
chart.data().datasets[1] = {
label: "average",
data: create_avg_dataset(metrics),
borderWidth: 1.5,
pointRadius: 0,
borderColor: "#00ff00",
backgroundColor: "#00ff00",
} }
} else {
chart.data().datasets.length = Object.keys(metrics).length
let i = 0
for (const host of Object.keys(metrics).sort()) { for (const host of Object.keys(metrics).sort()) {
if (chart.data().datasets[i] === undefined) { if (chart.data().datasets[i] === undefined) {
chart.data().datasets[i] = { chart.data().datasets[i] = {
@@ -51,16 +59,18 @@ const update_chart = async (timestamps: string[], metrics: {[key: string]: numbe
} }
} }
chart.data().datasets[i].label = await host_label(host) chart.data().datasets[i].label = await host_label(host)
chart.data().datasets[i].borderWidth = 1
chart.data().datasets[i].borderColor = host_colour(host) chart.data().datasets[i].borderColor = host_colour(host)
chart.data().datasets[i].backgroundColor = host_colour(host) chart.data().datasets[i].backgroundColor = host_colour(host)
chart.data().datasets[i].data = [...metrics[host]] chart.data().datasets[i].data = [...metrics[host]]
i++ i++
} }
}
chart.update() chart.update()
} }
const create_aggregate_dataset = (hosts: {[key:string]: number[]}): number[] => { const create_sum_dataset = (hosts: {[key:string]: number[]}): number[] => {
let data: number[] = [] let data: number[] = []
for (const host of Object.keys(hosts)) { for (const host of Object.keys(hosts)) {
for (let idx = 0; idx < hosts[host].length; idx++) { for (let idx = 0; idx < hosts[host].length; idx++) {
@@ -72,6 +82,16 @@ const create_aggregate_dataset = (hosts: {[key:string]: number[]}): number[] =>
} }
return data return data
} }
const create_avg_dataset = (hosts: {[key:string]: number[]}): number[] => {
// The calculate the average, we take the sum and divide it by the number of
// hosts
let data: number[] = create_sum_dataset(hosts)
const num_hosts = Object.keys(hosts).length
for (let idx=0; idx < data.length; idx++) {
data[idx] /= num_hosts
}
return data
}
</script> </script>
<div> <div>

View File

@@ -1,16 +1,36 @@
<script lang="ts"> <script lang="ts">
import { fs_encode_path } from "lib/FilesystemAPI.svelte"; import { fs_encode_path } from "lib/FilesystemAPI.svelte";
import { path_link, type FSNavigator } from "./FSNavigator"; import { path_link, type FSNavigator } from "./FSNavigator";
import { menu_is_open } from "wrap/MainMenu.svelte"; import FileMenu from "./filemanager/FileMenu.svelte";
import EditWindow from "./edit_window/EditWindow.svelte";
import { onMount } from "svelte";
import { breadcrumbs_store } from "wrap/BreadcrumbStore";
let { nav }: { let {
nav,
edit_window = null
}: {
nav: FSNavigator; nav: FSNavigator;
edit_window?: EditWindow;
} = $props(); } = $props();
let file_menu: FileMenu = $state()
onMount(() => {
return nav.subscribe(nav => {
breadcrumbs_store.set(breadcrumbs)
})
})
</script> </script>
<div class="breadcrumbs" class:menu_closed={!$menu_is_open}> {#snippet breadcrumbs()}
{#each $nav.path as node, i (node.path)} {#each $nav.path as node, i (node.path)}
<a href={"/d"+fs_encode_path(node.path)} class="breadcrumb button flat" use:path_link={{nav: nav, node: node}}> <a
href={"/d"+fs_encode_path(node.path)}
class="breadcrumb button flat"
use:path_link={{nav: nav, node: node}}
oncontextmenu={e => file_menu.open(node, e.target, e)}
>
{#if node.abuse_type !== undefined} {#if node.abuse_type !== undefined}
<i class="icon small">block</i> <i class="icon small">block</i>
{:else if node.is_shared()} {:else if node.is_shared()}
@@ -24,21 +44,11 @@ let { nav }: {
<i class="icon">chevron_right</i> <i class="icon">chevron_right</i>
{/if} {/if}
{/each} {/each}
</div>
<FileMenu bind:this={file_menu} nav={nav} edit_window={edit_window} />
{/snippet}
<style> <style>
.breadcrumbs {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: left;
flex-wrap: wrap;
flex-direction: row;
overflow: hidden;
background: var(--shaded_background);
backdrop-filter: blur(4px);
border-bottom: 1px solid var(--separator);
}
.breadcrumb { .breadcrumb {
min-width: 1em; min-width: 1em;
text-align: center; text-align: center;
@@ -58,7 +68,4 @@ let { nav }: {
/* The base name uses all available space */ /* The base name uses all available space */
max-width: unset; max-width: unset;
} }
.menu_closed {
padding-left: 2em;
}
</style> </style>

View File

@@ -172,12 +172,13 @@ run(() => {
<td>Mode</td> <td>Mode</td>
<td>{$nav.base.mode_string}</td> <td>{$nav.base.mode_string}</td>
</tr> </tr>
{#if $nav.base.id}
<tr> <tr>
<td>Public ID</td> <td>Node ID</td>
<td><a href="/d/{$nav.base.id}">{$nav.base.id}</a></td> <td>
<CopyButton text={$nav.base.id}>Copy</CopyButton>
<a href="/d/{$nav.base.id}">{$nav.base.id}</a>
</td>
</tr> </tr>
{/if}
{#if $nav.base.type === "file"} {#if $nav.base.type === "file"}
<tr> <tr>
<td>File type</td> <td>File type</td>

View File

@@ -10,6 +10,7 @@ import { global_navigator } from "./FSNavigator"
import { css_from_path } from "filesystem/edit_window/Branding"; import { css_from_path } from "filesystem/edit_window/Branding";
import AffiliatePrompt from "user_home/AffiliatePrompt.svelte"; import AffiliatePrompt from "user_home/AffiliatePrompt.svelte";
import { current_page_store } from "wrap/RouterStore"; import { current_page_store } from "wrap/RouterStore";
import SearchBar from "./SearchBar.svelte";
let file_preview: FilePreview = $state() let file_preview: FilePreview = $state()
let toolbar: Toolbar = $state() let toolbar: Toolbar = $state()
@@ -18,6 +19,7 @@ let details_visible = $state(false)
let edit_window: EditWindow = $state() let edit_window: EditWindow = $state()
let edit_visible = $state(false) let edit_visible = $state(false)
let details_window: DetailsWindow = $state() let details_window: DetailsWindow = $state()
let search_bar: SearchBar = $state()
const nav = global_navigator const nav = global_navigator
@@ -54,7 +56,13 @@ onMount(() => {
const keydown = (e: KeyboardEvent) => { const keydown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.altKey || e.metaKey) { if (e.ctrlKey || e.altKey || e.metaKey) {
return // prevent custom shortcuts from interfering with system shortcuts return // prevent custom shortcuts from interfering with system shortcuts
} else if ((document.activeElement as any).type !== undefined && (document.activeElement as any).type === "text") { } else if (
(document.activeElement as any).type !== undefined &&
(
(document.activeElement as any).type === "text" ||
(document.activeElement as any).type === "textarea"
)
) {
return // Prevent shortcuts from interfering with input fields return // Prevent shortcuts from interfering with input fields
} }
@@ -79,6 +87,9 @@ const keydown = (e: KeyboardEvent) => {
case "r": case "r":
nav.shuffle = !nav.shuffle nav.shuffle = !nav.shuffle
break; break;
case "/":
search_bar.open()
break;
case "a": case "a":
case "ArrowLeft": case "ArrowLeft":
nav.open_sibling(-1) nav.open_sibling(-1)
@@ -131,7 +142,7 @@ const keydown = (e: KeyboardEvent) => {
<svelte:window onkeydown={keydown} /> <svelte:window onkeydown={keydown} />
<div class="filesystem"> <div class="filesystem">
<Breadcrumbs nav={nav}/> <Breadcrumbs nav={nav} edit_window={edit_window}/>
<div class="file_preview"> <div class="file_preview">
<FilePreview <FilePreview
@@ -140,6 +151,7 @@ const keydown = (e: KeyboardEvent) => {
upload_widget={upload_widget} upload_widget={upload_widget}
edit_window={edit_window} edit_window={edit_window}
details_window={details_window} details_window={details_window}
search_bar={search_bar}
/> />
</div> </div>
@@ -152,6 +164,8 @@ const keydown = (e: KeyboardEvent) => {
/> />
</div> </div>
<SearchBar bind:this={search_bar} nav={nav}/>
<DetailsWindow nav={nav} bind:this={details_window} bind:visible={details_visible} /> <DetailsWindow nav={nav} bind:this={details_window} bind:visible={details_visible} />
<EditWindow nav={nav} bind:this={edit_window} bind:visible={edit_visible} /> <EditWindow nav={nav} bind:this={edit_window} bind:visible={edit_visible} />
@@ -177,8 +191,7 @@ const keydown = (e: KeyboardEvent) => {
.filesystem { .filesystem {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; flex: 1 1 auto; /* auto grow */
width: 100%;
} }
.file_preview { .file_preview {

View File

@@ -1,13 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount, tick } from "svelte";
import { fs_search, fs_encode_path, fs_thumbnail_url } from "lib/FilesystemAPI.svelte"; import { fs_search, fs_encode_path, fs_thumbnail_url, FSNode } from "lib/FilesystemAPI.svelte";
import type { FSNavigator } from "filesystem/FSNavigator"; import type { FSNavigator } from "filesystem/FSNavigator";
import { loading_finish, loading_start } from "lib/Loading"; import { loading_finish, loading_start } from "lib/Loading";
import Modal from "util/Modal.svelte";
let { nav }: { let { nav }: {
nav: FSNavigator; nav: FSNavigator;
} = $props(); } = $props();
let modal: Modal
let search_bar: HTMLInputElement = $state() let search_bar: HTMLInputElement = $state()
let error = $state("") let error = $state("")
let search_term = $state("") let search_term = $state("")
@@ -17,6 +19,10 @@ let searching = false
let last_searched_term = "" let last_searched_term = ""
let last_limit = $state(10) let last_limit = $state(10)
// The path in which we're searching. This is used for stripping the path prefix
// of search results, and showing the title of the Modal
let search_prefix_node: FSNode = $state({} as FSNode)
onMount(() => { onMount(() => {
// Clear results when the user moves to a new directory // Clear results when the user moves to a new directory
return nav.subscribe(nav => { return nav.subscribe(nav => {
@@ -26,6 +32,13 @@ onMount(() => {
}) })
}) })
export const open = async () => {
search_prefix_node = nav.base
modal.show()
await tick()
search_bar.focus()
}
const search = async (limit = 10) => { const search = async (limit = 10) => {
if (search_term.length < 2 || search_term.length > 100) { if (search_term.length < 2 || search_term.length > 100) {
// These are the length limits defined by the API // These are the length limits defined by the API
@@ -49,7 +62,23 @@ const search = async (limit = 10) => {
try { try {
loading_start() loading_start()
search_results = await fs_search(nav.base.path, search_term, limit) let search_node = nav.base
// If the base is not a directory, we use the parent
if (search_node.type !== "dir") {
search_node = nav.path[nav.path.length-2]
}
let search_results_tmp = await fs_search(search_node.path, search_term, limit)
// If there are no results, and we are not searching the filesystem
// root, we will search the filesystem root
if (search_results_tmp.length === 0 && search_node.path != nav.path[0].path) {
search_node = nav.path[0]
search_results_tmp = await fs_search(search_node.path, search_term, limit)
}
search_prefix_node = search_node
search_results = search_results_tmp
} catch (err) { } catch (err) {
if (err.value) { if (err.value) {
error = err.value error = err.value
@@ -68,7 +97,7 @@ const search = async (limit = 10) => {
searching = false searching = false
// It's possible that the user entered another letter while we were // It's possible that the user entered another letter while we were
// performing the search reqeust. If this happens we run the search function // performing the search request. If this happens we run the search function
// again // again
if (last_searched_term !== search_term) { if (last_searched_term !== search_term) {
console.debug("Search term changed during search. Searching again") console.debug("Search term changed during search. Searching again")
@@ -90,6 +119,8 @@ const clear_search = (blur: boolean) => {
if (blur) { if (blur) {
search_bar.blur() search_bar.blur()
} }
modal.hide()
} }
// Cursor navigation events can only be prevented with keydown. But we want to // Cursor navigation events can only be prevented with keydown. But we want to
@@ -144,7 +175,8 @@ const window_keydown = (e: KeyboardEvent) => {
} else if (e.key === "/" || e.key === "f") { } else if (e.key === "/" || e.key === "f") {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
search_bar.focus() // search_bar.focus()
open()
return return
} }
} }
@@ -152,19 +184,20 @@ const window_keydown = (e: KeyboardEvent) => {
<svelte:window onkeydown={window_keydown} /> <svelte:window onkeydown={window_keydown} />
<Modal bind:this={modal} title="Searching in {search_prefix_node.name}">
{#if error === "path_not_found" || error === "node_is_a_directory"} {#if error === "path_not_found" || error === "node_is_a_directory"}
<div class="highlight_yellow center"> <div class="highlight_yellow center">
Search index not found. The search index is a file called Search index not found. The search index is a file called
'.search_index.gz' in your home directory. If you delete this file then '.search_index.zstd' in your home directory. If you delete this file
search will not work. The file is regenerated 10 minutes after modifying then search will not work. The file is regenerated 10 minutes after
a file in your filesystem. modifying a file in your filesystem.
</div> </div>
{:else if error !== ""} {:else if error !== ""}
<div class="highlight_red center"> <div class="highlight_red center">
An error ocurred while executing the search request: {error} An error ocurred while executing the search request: {error}
</div> </div>
{/if} {/if}
<div class="center"> <div class="center">
<form class="search_form" onsubmit={submit_search}> <form class="search_form" onsubmit={submit_search}>
<i class="icon">search</i> <i class="icon">search</i>
@@ -172,18 +205,12 @@ const window_keydown = (e: KeyboardEvent) => {
bind:this={search_bar} bind:this={search_bar}
class="term" class="term"
type="text" type="text"
placeholder="Press / to search in {$nav.base.name}" placeholder="Enter search term"
style="width: 100%;" style="width: 100%;"
bind:value={search_term} bind:value={search_term}
onkeydown={input_keydown} onkeydown={input_keydown}
onkeyup={input_keyup} onkeyup={input_keyup}
/> />
{#if search_term !== ""}
<!-- Button needs to be of button type in order to not submit the form -->
<button onclick={() => clear_search(false)} type="button">
<i class="icon">close</i>
</button>
{/if}
</form> </form>
<div class="results"> <div class="results">
@@ -201,7 +228,7 @@ const window_keydown = (e: KeyboardEvent) => {
<img src={fs_thumbnail_url(result, 32, 32)} class="node_icon" alt="icon"/> <img src={fs_thumbnail_url(result, 32, 32)} class="node_icon" alt="icon"/>
<span class="node_name"> <span class="node_name">
<!-- Remove the search directory from the result --> <!-- Remove the search directory from the result -->
{result.slice($nav.base.path.length+1)} {result.slice(search_prefix_node.path.length+1)}
</span> </span>
</a> </a>
{/each} {/each}
@@ -218,6 +245,7 @@ const window_keydown = (e: KeyboardEvent) => {
{/if} {/if}
</div> </div>
</div> </div>
</Modal>
<style> <style>
.center { .center {
@@ -247,9 +275,7 @@ const window_keydown = (e: KeyboardEvent) => {
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
max-height: 80vh; max-height: 80vh;
text-align: left; text-align: initial;
background: var(--body_color);
border-radius: 8px;
} }
.results > * { .results > * {
display: flex; display: flex;

View File

@@ -21,7 +21,7 @@ let {
let share_dialog: ShareDialog = $state() let share_dialog: ShareDialog = $state()
let link_copied = $state(false) let link_copied = $state(false)
let toolbar_expanded = $state(false) let toolbar_expanded = $state(true)
export const copy_link = () => { export const copy_link = () => {
const share_url = fs_share_url($nav.path) const share_url = fs_share_url($nav.path)
@@ -113,7 +113,8 @@ export const copy_link = () => {
border-top: 1px solid var(--separator); border-top: 1px solid var(--separator);
background: var(--shaded_background); background: var(--shaded_background);
backdrop-filter: blur(4px); position: sticky;
bottom: 0;
} }
.grid { .grid {
display: grid; display: grid;

View File

@@ -4,6 +4,9 @@ import hsl2rgb from "pure-color/convert/hsl2rgb";
import rgb2hex from "pure-color/convert/rgb2hex"; import rgb2hex from "pure-color/convert/rgb2hex";
import type { FSNode, FSNodeProperties } from "lib/FilesystemAPI.svelte"; import type { FSNode, FSNodeProperties } from "lib/FilesystemAPI.svelte";
const text_contrast = 75
const body_alpha = 0.9
type Style = { type Style = {
input_background?: string, input_background?: string,
input_hover_background?: string, input_hover_background?: string,
@@ -69,12 +72,12 @@ const add_styles = (style: Style, properties: FSNodeProperties) => {
if (properties.brand_input_color) { if (properties.brand_input_color) {
style.input_background = properties.brand_input_color style.input_background = properties.brand_input_color
style.input_hover_background = properties.brand_input_color style.input_hover_background = properties.brand_input_color
style.input_text = add_contrast(properties.brand_input_color, 75) style.input_text = add_contrast(properties.brand_input_color, text_contrast)
} }
if (properties.brand_highlight_color) { if (properties.brand_highlight_color) {
style.highlight_color = properties.brand_highlight_color style.highlight_color = properties.brand_highlight_color
style.highlight_background = properties.brand_highlight_color style.highlight_background = properties.brand_highlight_color
style.highlight_text_color = add_contrast(properties.brand_highlight_color, 75) style.highlight_text_color = add_contrast(properties.brand_highlight_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 highlight colour
// to generate the link colour // to generate the link colour
@@ -84,20 +87,20 @@ const add_styles = (style: Style, properties: FSNodeProperties) => {
} }
if (properties.brand_danger_color) { if (properties.brand_danger_color) {
style.danger_color = properties.brand_danger_color style.danger_color = properties.brand_danger_color
style.danger_text_color = add_contrast(properties.brand_danger_color, 75) style.danger_text_color = add_contrast(properties.brand_danger_color, text_contrast)
} }
if (properties.brand_background_color) { if (properties.brand_background_color) {
style.background_color = properties.brand_background_color style.background_color = properties.brand_background_color
style.background = properties.brand_background_color style.background = properties.brand_background_color
style.background_text_color = add_contrast(properties.brand_background_color, 75) style.background_text_color = add_contrast(properties.brand_background_color, text_contrast)
style.background_pattern_color = properties.brand_background_color style.background_pattern_color = properties.brand_background_color
} }
if (properties.brand_body_color) { if (properties.brand_body_color) {
style.body_color = properties.brand_body_color style.body_color = properties.brand_body_color
style.body_background = properties.brand_body_color style.body_background = set_alpha(properties.brand_body_color, body_alpha)
style.body_text_color = add_contrast(properties.brand_body_color, 75) style.body_text_color = add_contrast(properties.brand_body_color, text_contrast)
style.shaded_background = set_alpha(properties.brand_body_color, 0.75) style.shaded_background = set_alpha(properties.brand_body_color, body_alpha)
style.separator = add_contrast(properties.brand_body_color, 8) style.separator = add_contrast(properties.brand_body_color, 15)
style.shadow_color = darken(properties.brand_body_color, 0.8) style.shadow_color = darken(properties.brand_body_color, 0.8)
} }
if (properties.brand_card_color) { if (properties.brand_card_color) {

View File

@@ -29,7 +29,7 @@ const themes = [
brand_danger_color: "#821b3f", brand_danger_color: "#821b3f",
brand_background_color: "#141414", brand_background_color: "#141414",
brand_body_color: "#1e1e1e", brand_body_color: "#1e1e1e",
brand_card_color: "#282828" brand_card_color: "#282828",
}, { }, {
name: "Nord (dark)", name: "Nord (dark)",
brand_input_color: "#4f596d", brand_input_color: "#4f596d",
@@ -37,7 +37,7 @@ const themes = [
brand_danger_color: "#bd5f69", brand_danger_color: "#bd5f69",
brand_background_color: "#2f3541", brand_background_color: "#2f3541",
brand_body_color: "#3b4252", brand_body_color: "#3b4252",
brand_card_color: "#434c5f" brand_card_color: "#434c5f",
}, { }, {
name: "Nord (light)", name: "Nord (light)",
brand_input_color: "#cad2e1", brand_input_color: "#cad2e1",
@@ -45,7 +45,23 @@ const themes = [
brand_danger_color: "#bd5f69", brand_danger_color: "#bd5f69",
brand_background_color: "#d7dde8", brand_background_color: "#d7dde8",
brand_body_color: "#ebeef3", brand_body_color: "#ebeef3",
brand_card_color: "#e5e9f0" brand_card_color: "#e5e9f0",
}, {
name: "Black",
brand_input_color: "#202020",
brand_highlight_color: "#57e389",
brand_danger_color: "#ed333b",
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_background_color: "#f8f8f8",
brand_body_color: "#ffffff",
brand_card_color: "#f0f0f0",
}, { }, {
name: "Solarized (dark)", name: "Solarized (dark)",
brand_input_color: "#084453", brand_input_color: "#084453",
@@ -53,7 +69,7 @@ const themes = [
brand_danger_color: "#db302d", brand_danger_color: "#db302d",
brand_background_color: "#002c38", brand_background_color: "#002c38",
brand_body_color: "#063540", brand_body_color: "#063540",
brand_card_color: "#073c49" brand_card_color: "#073c49",
}, { }, {
name: "Solarized (light)", name: "Solarized (light)",
brand_input_color: "#e7dfc5", brand_input_color: "#e7dfc5",
@@ -61,7 +77,7 @@ const themes = [
brand_danger_color: "#db302d", brand_danger_color: "#db302d",
brand_background_color: "#ede7d3", brand_background_color: "#ede7d3",
brand_body_color: "#fdf5e2", brand_body_color: "#fdf5e2",
brand_card_color: "#fcf2d8" brand_card_color: "#fcf2d8",
}, { }, {
name: "Mocha", name: "Mocha",
brand_input_color: "#313244", brand_input_color: "#313244",
@@ -69,7 +85,7 @@ const themes = [
brand_danger_color: "#f38ba8", brand_danger_color: "#f38ba8",
brand_background_color: "#1e1e2e", brand_background_color: "#1e1e2e",
brand_body_color: "#181825", brand_body_color: "#181825",
brand_card_color: "#313244" brand_card_color: "#313244",
} }
] ]
</script> </script>

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { run } from 'svelte/legacy'; import { fs_delete_all, fs_rename } from "lib/FilesystemAPI.svelte"
import { fs_delete_all, fs_rename, type FSNode } from "lib/FilesystemAPI.svelte"
import { onMount } from "svelte" import { onMount } from "svelte"
import CreateDirectory from "./CreateDirectory.svelte" import CreateDirectory from "./CreateDirectory.svelte"
import ListView from "./ListView.svelte" import ListView from "./ListView.svelte"
@@ -9,24 +8,26 @@ import CompactView from "./CompactView.svelte"
import Button from "layout/Button.svelte"; import Button from "layout/Button.svelte";
import { formatDate } from "util/Formatting"; import { formatDate } from "util/Formatting";
import { drop_target } from "lib/DropTarget" import { drop_target } from "lib/DropTarget"
import SearchBar from "./SearchBar.svelte";
import type { FSNavigator } from "filesystem/FSNavigator"; import type { FSNavigator } from "filesystem/FSNavigator";
import FsUploadWidget from "filesystem/upload_widget/FSUploadWidget.svelte"; import FsUploadWidget from "filesystem/upload_widget/FSUploadWidget.svelte";
import EditWindow from "filesystem/edit_window/EditWindow.svelte"; import EditWindow from "filesystem/edit_window/EditWindow.svelte";
import { FileAction, type FileActionHandler } from "./FileManagerLib"; import { FileAction, type FileActionHandler } from "./FileManagerLib";
import FileMenu from "./FileMenu.svelte"; import FileMenu from "./FileMenu.svelte";
import { loading_finish, loading_start } from "lib/Loading"; import { loading_finish, loading_start } from "lib/Loading";
import SearchBar from "../SearchBar.svelte";
let { let {
nav = $bindable(), nav = $bindable(),
upload_widget, upload_widget,
edit_window = $bindable(), edit_window = $bindable(),
search_bar = $bindable(),
directory_view = $bindable(""), directory_view = $bindable(""),
children children
}: { }: {
nav: FSNavigator; nav: FSNavigator;
upload_widget: FsUploadWidget; upload_widget: FsUploadWidget;
edit_window: EditWindow; edit_window: EditWindow;
search_bar: SearchBar;
directory_view?: string; directory_view?: string;
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
} = $props(); } = $props();
@@ -225,18 +226,21 @@ const select_node = (index: number) => {
last_selected_node = index last_selected_node = index
} }
const update = (children: FSNode[]) => { // When the directory is reloaded we want to keep our selection, so this
// function watches the children array for changes and updates the selection
// when it changes
$effect.pre(() => {
creating_dir = false creating_dir = false
// Highlight the files which were previously selected // Highlight the files which were previously selected
for (let i = 0; i < children.length; i++) { for (let i = 0; i < $nav.children.length; i++) {
for (let j = 0; j < moving_items.length; j++) { for (let j = 0; j < moving_items.length; j++) {
if (moving_items[j].path === children[i].path) { if (moving_items[j].path === $nav.children[i].path) {
children[i].fm_selected = true $nav.children[i].fm_selected = true
}
} }
} }
} }
})
let moving_files = $state(0) let moving_files = $state(0)
let moving_directories = $state(0) let moving_directories = $state(0)
@@ -289,12 +293,6 @@ onMount(() => {
directory_view = "list" directory_view = "list"
} }
}) })
// When the directory is reloaded we want to keep our selection, so this
// function watches the children array for changes and updates the selection
// when it changes
run(() => {
update($nav.children)
});
</script> </script>
<svelte:window onkeydown={keypress} onkeyup={keypress} /> <svelte:window onkeydown={keypress} onkeyup={keypress} />
@@ -310,6 +308,9 @@ run(() => {
{#if mode === "viewing"} {#if mode === "viewing"}
<div class="toolbar"> <div class="toolbar">
<div class="toolbar_left"> <div class="toolbar_left">
<button onclick={() => {search_bar.open()}} title="Press / to search">
<i class="icon">search</i>
</button>
<button onclick={navigate_back} title="Back"> <button onclick={navigate_back} title="Back">
<i class="icon">arrow_back</i> <i class="icon">arrow_back</i>
</button> </button>
@@ -360,8 +361,6 @@ run(() => {
</div> </div>
</div> </div>
<SearchBar nav={nav}/>
{:else if mode === "selecting"} {:else if mode === "selecting"}
<div class="toolbar toolbar_edit"> <div class="toolbar toolbar_edit">
<Button click={viewing_mode} icon="close"/> <Button click={viewing_mode} icon="close"/>

View File

@@ -10,16 +10,19 @@ import { tick } from "svelte";
let { let {
nav, nav,
edit_window edit_window = null
}: { }: {
nav: FSNavigator; nav: FSNavigator;
edit_window: EditWindow; edit_window?: EditWindow;
} = $props(); } = $props();
let dialog: Dialog = $state() let dialog: Dialog = $state()
let node: FSNode = $state(null) let node: FSNode = $state(null)
export const open = async (n: FSNode, target: EventTarget, event: Event) => { export const open = async (n: FSNode, target: EventTarget, event: Event) => {
event.preventDefault()
event.stopPropagation()
node = n node = n
// Wait for the view to update, so the dialog gets the proper measurements // Wait for the view to update, so the dialog gets the proper measurements
await tick() await tick()
@@ -61,10 +64,12 @@ const delete_node = async () => {
{/if} {/if}
{#if $nav.permissions.write} {#if $nav.permissions.write}
<Button click={() => {dialog.close(); delete_node()}} icon="delete" label="Delete"/> <Button click={() => {dialog.close(); delete_node()}} icon="delete" label="Delete"/>
{#if edit_window !== null}
<Button click={() => {dialog.close(); edit_window.edit(node, false, "file")}} icon="edit" label="Edit"/> <Button click={() => {dialog.close(); edit_window.edit(node, false, "file")}} icon="edit" label="Edit"/>
<Button click={() => {dialog.close(); edit_window.edit(node, false, "share")}} icon="share" label="Share"/> <Button click={() => {dialog.close(); edit_window.edit(node, false, "share")}} icon="share" label="Share"/>
<Button click={() => {dialog.close(); edit_window.edit(node, false, "branding")}} icon="palette" label="Branding"/> <Button click={() => {dialog.close(); edit_window.edit(node, false, "branding")}} icon="palette" label="Branding"/>
{/if} {/if}
{/if}
</div> </div>
</Dialog> </Dialog>

View File

@@ -4,9 +4,8 @@ import ListView from "./ListView.svelte"
import GalleryView from "./GalleryView.svelte" import GalleryView from "./GalleryView.svelte"
import CompactView from "./CompactView.svelte" import CompactView from "./CompactView.svelte"
import Modal from "util/Modal.svelte"; import Modal from "util/Modal.svelte";
import Breadcrumbs from "filesystem/Breadcrumbs.svelte" import { FSNavigator, path_link } from "filesystem/FSNavigator";
import { FSNavigator } from "filesystem/FSNavigator"; import { fs_encode_path, type FSNode } from "lib/FilesystemAPI.svelte";
import type { FSNode } from "lib/FilesystemAPI.svelte";
import { FileAction, type FileActionHandler } from "./FileManagerLib"; import { FileAction, type FileActionHandler } from "./FileManagerLib";
let nav = $state(new FSNavigator(false)) let nav = $state(new FSNavigator(false))
@@ -171,7 +170,26 @@ onMount(() => {
</div> </div>
{/snippet} {/snippet}
<Breadcrumbs nav={nav}/> {#each $nav.path as node, i (node.path)}
<a
href={"/d"+fs_encode_path(node.path)}
class="breadcrumb button flat"
use:path_link={{nav: nav, node: node}}
>
{#if node.abuse_type !== undefined}
<i class="icon small">block</i>
{:else if node.is_shared()}
<i class="icon small">share</i>
{/if}
<div class="node_name" class:base={$nav.base_index === i}>
{node.name}
</div>
</a>
{#if $nav.base_index !== i}
<i class="icon">chevron_right</i>
{/if}
{/each}
{#if directory_view === "list"} {#if directory_view === "list"}
<ListView <ListView

View File

@@ -64,7 +64,7 @@ onMount(() => {
{@render children?.()} {@render children?.()}
<div bind:this={background_div} class="background_div"> <div bind:this={background_div} class="background_div">
<TextBlock width="1000px"> <TextBlock width="auto">
<audio <audio
bind:this={player} bind:this={player}
class="player" class="player"

View File

@@ -34,7 +34,7 @@ let {
</button> </button>
</IconBlock> </IconBlock>
{#if node.name === ".search_index.gz"} {#if node.name === ".search_index.zstd"}
<TextBlock> <TextBlock>
<p> <p>
Congratulations! You have found the search index. One of the Congratulations! You have found the search index. One of the
@@ -50,7 +50,7 @@ let {
have a lot of repetitive elements it compresses incredibly well. have a lot of repetitive elements it compresses incredibly well.
You'd be hard-pressed to grow this index over even 1 MB. Honestly, You'd be hard-pressed to grow this index over even 1 MB. Honestly,
this search system is incredibly efficient, I'd be surprised if this search system is incredibly efficient, I'd be surprised if
EleasticSearch could even match it. ElasticSearch could even match it.
</p> </p>
<p> <p>
This file is updated 10 minutes after the last time you modify a This file is updated 10 minutes after the last time you modify a

View File

@@ -16,17 +16,20 @@ import type { FSNavigator } from "filesystem/FSNavigator";
import FsUploadWidget from "filesystem/upload_widget/FSUploadWidget.svelte"; import FsUploadWidget from "filesystem/upload_widget/FSUploadWidget.svelte";
import EditWindow from "filesystem/edit_window/EditWindow.svelte"; import EditWindow from "filesystem/edit_window/EditWindow.svelte";
import DetailsWindow from "filesystem/DetailsWindow.svelte"; import DetailsWindow from "filesystem/DetailsWindow.svelte";
import SearchBar from "filesystem/SearchBar.svelte";
let { let {
nav, nav,
upload_widget, upload_widget,
edit_window, edit_window,
details_window, details_window,
search_bar,
}: { }: {
nav: FSNavigator nav: FSNavigator
upload_widget: FsUploadWidget upload_widget: FsUploadWidget
edit_window: EditWindow edit_window: EditWindow
details_window: DetailsWindow details_window: DetailsWindow
search_bar: SearchBar
} = $props(); } = $props();
let viewer: any = $state() let viewer: any = $state()
@@ -86,7 +89,7 @@ export const seek = (delta: number) => {
<Spinner/> <Spinner/>
</div> </div>
{:else if viewer_type === "dir"} {:else if viewer_type === "dir"}
<FileManager nav={nav} upload_widget={upload_widget} edit_window={edit_window}> <FileManager nav={nav} upload_widget={upload_widget} edit_window={edit_window} search_bar={search_bar}>
<CustomBanner path={$nav.path}/> <CustomBanner path={$nav.path}/>
</FileManager> </FileManager>
{:else if viewer_type === "audio"} {:else if viewer_type === "audio"}

View File

@@ -11,9 +11,6 @@ let upload_widget
</script> </script>
<header class="logo_header"> <header class="logo_header">
<div class="menu_button_container">
<Menu no_login_label="Not logged in" hide_name={false} hide_logo style="border-radius: 0 0 0 8px; margin: 0"/>
</div>
<div class="header_image_container"></div> <div class="header_image_container"></div>
</header> </header>
@@ -139,15 +136,14 @@ header {
} }
header > h1 { header > h1 {
color: #ffffff; color: #ffffff;
text-shadow: 0 0 6px #000000; text-shadow: 1px 1px 5px #000000;
margin-top: 30px; margin-top: 30px;
margin-bottom: 30px; margin-bottom: 30px;
} }
.header_image_container { .header_image_container {
text-align: initial; text-align: initial;
margin: auto; margin: 50px auto;
margin-bottom: 50px;
height: 100px; height: 100px;
width: 500px; width: 500px;
max-width: 100%; max-width: 100%;
@@ -156,10 +152,6 @@ header > h1 {
background-size: contain; background-size: contain;
background-position: center; background-position: center;
} }
.menu_button_container {
display: flex;
justify-content: end;
}
.image { .image {
max-width: 100%; max-width: 100%;
border-radius: 12px; border-radius: 12px;
@@ -167,7 +159,6 @@ header > h1 {
.bold { .bold {
font-weight: bold; font-weight: bold;
color: var(--highlight_color); color: var(--highlight_color);
text-shadow: 1px 1px 3px var(--shadow_color);
} }
.prices { .prices {

View File

@@ -7,6 +7,7 @@ let {
group_middle = false, group_middle = false,
group_last = false, group_last = false,
highlight = true, highlight = true,
flat = false,
action, action,
children, children,
}: { }: {
@@ -17,6 +18,7 @@ let {
group_middle?: boolean; group_middle?: boolean;
group_last?: boolean; group_last?: boolean;
highlight?: boolean; highlight?: boolean;
flat?: boolean;
action?: (e: MouseEvent) => void; action?: (e: MouseEvent) => void;
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
} = $props(); } = $props();
@@ -37,6 +39,7 @@ const click = (e: MouseEvent) => {
class:group_first class:group_first
class:group_middle class:group_middle
class:group_last class:group_last
class:flat
> >
{#if on} {#if on}
<i class="icon">{icon_on}</i> <i class="icon">{icon_on}</i>

View File

@@ -1,5 +1,5 @@
// Dead zone before the swipe action gets detected // Dead zone before the swipe action gets detected
const swipe_inital_offset = 25 const swipe_initial_offset = 25
// Amount of pixels after which the navigation triggers // Amount of pixels after which the navigation triggers
const swipe_trigger_offset = 75 const swipe_trigger_offset = 75
@@ -38,8 +38,8 @@ export const swipe_nav = (
// The cursor must have moved at least 50 pixels and three times as much // The cursor must have moved at least 50 pixels and three times as much
// on the x axis than the y axis for it to count as a swipe // on the x axis than the y axis for it to count as a swipe
if (abs_x > swipe_inital_offset && abs_y < abs_x / 3) { if (abs_x > swipe_initial_offset && abs_y < abs_x / 3) {
set_offset((abs_x - swipe_inital_offset) * neg, false) set_offset((abs_x - swipe_initial_offset) * neg, false)
} else { } else {
set_offset(0, true) set_offset(0, true)
} }

View File

@@ -186,7 +186,7 @@ onMount(() => {
}) })
</script> </script>
<section class="highlight_border"> <section>
<div style="text-align: center"> <div style="text-align: center">
<Button icon="speed" label="Start test" click={() => start(12000)} disabled={running} highlight={!running}/> <Button icon="speed" label="Start test" click={() => start(12000)} disabled={running} highlight={!running}/>
<Button icon="speed" label="Long test" click={() => start(30000)} disabled={running}/> <Button icon="speed" label="Long test" click={() => start(30000)} disabled={running}/>

View File

@@ -168,7 +168,6 @@ onMount(() => {
<style> <style>
.separator { .separator {
border-top: 1px solid var(--separator);
margin: 0 8px; margin: 0 8px;
} }
.cards { .cards {

View File

@@ -12,6 +12,7 @@ export type Tab = {
</script> </script>
<script lang="ts"> <script lang="ts">
import { onMount, type Component } from "svelte"; import { onMount, type Component } from "svelte";
import { breadcrumbs_store } from "wrap/BreadcrumbStore";
import { current_page_store } from "wrap/RouterStore"; import { current_page_store } from "wrap/RouterStore";
let { title = $bindable(""), pages = $bindable([]) }: { let { title = $bindable(""), pages = $bindable([]) }: {
@@ -50,6 +51,8 @@ const get_page = () => {
title = current_subpage === null ? current_page.title : current_subpage.title title = current_subpage === null ? current_page.title : current_subpage.title
window.document.title = title+" / Nova" window.document.title = title+" / Nova"
breadcrumbs_store.set(breadcrumbs)
} }
let current_page: Tab = $state(null) let current_page: Tab = $state(null)
@@ -61,6 +64,14 @@ onMount(() => {
}) })
</script> </script>
{#snippet breadcrumbs()}
{current_page.title}
{#if current_subpage !== null}
<i class="icon">chevron_right</i>
{current_subpage.title}
{/if}
{/snippet}
{#if current_page !== null && current_page.hide_frame !== true} {#if current_page !== null && current_page.hide_frame !== true}
<header> <header>
<div class="tab_bar"> <div class="tab_bar">
@@ -106,14 +117,15 @@ onMount(() => {
{/if} {/if}
<style> <style>
header {
margin-top: 2em; header, .submenu {
}
.submenu {
border-bottom: 1px solid var(--separator); border-bottom: 1px solid var(--separator);
} }
.tab_bar > .button { .tab_bar > .button {
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
} }
.button_highlight {
background: var(--body_background);
}
</style> </style>

View File

@@ -79,7 +79,7 @@ const drop = (e: DragEvent, drop_idx: number) => {
<MenuEntry id="bookmarks" collapsed={menu_collapsed}> <MenuEntry id="bookmarks" collapsed={menu_collapsed}>
{#snippet title()} {#snippet title()}
<div class="title">Bookmarks</div> <div class="title">Bookmarks</div>
<button onclick={toggle_edit} class:button_highlight={editing}> <button onclick={toggle_edit} class:button_highlight={editing} class="button flat">
{#if editing} {#if editing}
<i class="icon">save</i> <i class="icon">save</i>
{:else} {:else}
@@ -129,7 +129,6 @@ const drop = (e: DragEvent, drop_idx: number) => {
<style> <style>
.title { .title {
flex: 1 1 auto; flex: 1 1 auto;
text-align: center;
} }
.row { .row {
display: flex; display: flex;

View File

@@ -0,0 +1,4 @@
import type { Snippet } from "svelte";
import { writable } from "svelte/store";
export let breadcrumbs_store = writable(null as Snippet);

View File

@@ -17,9 +17,23 @@ import { get_user } from "lib/PixeldrainAPI";
import Tree from "./Tree.svelte"; import Tree from "./Tree.svelte";
import MenuEntry from "./MenuEntry.svelte"; import MenuEntry from "./MenuEntry.svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { breadcrumbs_store } from "./BreadcrumbStore";
// The menu swipe will be detected if it was less than this much pixels from the
// screen edge
const screen_edge_offset = 50
// Dead zone before the swipe action gets detected
const swipe_dead_zone = screen_edge_offset/4
// If the screen is less wide than this, the menu will appear full screen
const min_screen_size_fullscreen_menu = 500
// If the screen is smaller than this the menu will be closed by default
const min_screen_size_menu_open = 800
onMount(() => { onMount(() => {
if (document.documentElement.clientWidth < 1000) { if (document.documentElement.clientWidth < min_screen_size_menu_open) {
menu_close() menu_close()
} }
@@ -33,21 +47,20 @@ onMount(() => {
}) })
}) })
// Dead zone before the swipe action gets detected
const swipe_initial_offset = 50
const min_screen_size_fullscreen_menu = 500
let menu: HTMLDivElement let menu: HTMLDivElement
let nav: HTMLElement let nav: HTMLElement
let dragging: boolean = false let dragging: boolean = false
let start_x: number let start_x: number
let start_y: number
let render_offset: number let render_offset: number
let initial_offset: number let initial_offset: number
const touchstart = (e: TouchEvent) => { const touchstart = (e: TouchEvent) => {
start_x = e.touches[0].clientX start_x = e.touches[0].clientX
start_y = e.touches[0].clientY
const rect = menu.getBoundingClientRect() const rect = menu.getBoundingClientRect()
if (start_x < Math.max(swipe_initial_offset, (rect.width+rect.left))) { if (start_x < Math.max(screen_edge_offset, (rect.width+rect.left))) {
dragging = true dragging = true
e.stopPropagation()
} }
render_offset = rect.left render_offset = rect.left
@@ -58,7 +71,19 @@ const touchmove = (e: TouchEvent) => {
if (!dragging) { if (!dragging) {
return return
} }
set_offset(initial_offset+(e.touches[0].clientX - start_x))
const x = e.touches[0].clientX - start_x
const y = e.touches[0].clientY - start_y
const abs_x = Math.abs(x)
const abs_y = Math.abs(y)
// The cursor must have moved at least swipe_dead_zone pixels and three
// times as much on the x axis than the y axis for it to count as a swipe
if (abs_x > swipe_dead_zone && abs_x / 3 > abs_y) {
set_offset(initial_offset+(x*2))
} else {
set_offset(initial_offset)
}
} }
const touchend = (e: TouchEvent) => { const touchend = (e: TouchEvent) => {
@@ -112,7 +137,7 @@ const menu_close = () => {
const set_offset = (off: number) => { const set_offset = (off: number) => {
render_offset = off render_offset = off
if (off > -swipe_initial_offset) { if (off > -swipe_dead_zone) {
// Clear the transformation if the offset is zero // Clear the transformation if the offset is zero
menu.style.transform = "" menu.style.transform = ""
} else { } else {
@@ -123,13 +148,16 @@ const set_offset = (off: number) => {
<svelte:window ontouchstart={touchstart} ontouchmove={touchmove} ontouchend={touchend}/> <svelte:window ontouchstart={touchstart} ontouchmove={touchmove} ontouchend={touchend}/>
<button class="button_toggle_navigation" onclick={toggle_menu}> <div class="wrap">
<i class="icon">menu</i>
</button>
<div class="nav_container" bind:this={menu}> <div class="nav_container" bind:this={menu}>
<div class="scroll_container"> <div class="scroll_container">
<nav class="nav" bind:this={nav}> <nav class="nav" bind:this={nav}>
{#if document.documentElement.clientWidth < min_screen_size_menu_open}
<button class="button" onclick={toggle_menu}>
<i class="icon">menu</i>
Menu
</button>
{/if}
<a class="button" href="/" use:highlight_current_page> <a class="button" href="/" use:highlight_current_page>
<i class="icon">home</i> <i class="icon">home</i>
<span>Home</span> <span>Home</span>
@@ -202,6 +230,18 @@ const set_offset = (off: number) => {
</div> </div>
<div class="page"> <div class="page">
<div class="header">
<button class="menu_button" onclick={toggle_menu}>
<i class="icon">menu</i>
</button>
<div class="breadcrumbs">
{#if $breadcrumbs_store !== null}
{@render $breadcrumbs_store()}
{/if}
</div>
</div>
<Router/> <Router/>
</div> </div>
@@ -212,39 +252,27 @@ const set_offset = (off: number) => {
</div> </div>
{/if} {/if}
</div>
<style> <style>
:global(body) { .wrap {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
color: var(--body_text_color); color: var(--body_text_color);
background-image: var(--background_image); background-image: var(--background_image);
background-color: var(--background_pattern_color); background-color: var(--background_color);
background-size: var(--background_image_size, initial); background-size: var(--background_image_size, initial);
background-position: var(--background_image_position, initial); background-position: var(--background_image_position, initial);
background-repeat: var(--background_image_repeat, repeat); background-repeat: var(--background_image_repeat, repeat);
background-attachment: fixed; background-attachment: fixed;
} width: 100%;
.button_toggle_navigation { min-height: 100vh;
position: fixed;
backface-visibility: hidden;
z-index: 10;
top: 0;
left: 0;
background: none;
box-shadow: none;
margin: 0;
padding: 4px;
border-radius: 0;
border-bottom-right-radius: 0px;
border-bottom-right-radius: 2px;
backdrop-filter: blur(6px);
} }
.nav_container { .nav_container {
flex: 0 0 auto; flex: 0 0 auto;
border-right: 1px solid var(--separator); border-right: 1px solid var(--separator);
background: var(--shaded_background); background: var(--body_background);
backdrop-filter: blur(6px);
z-index: 9; z-index: 9;
} }
.scroll_container { .scroll_container {
@@ -259,9 +287,6 @@ const set_offset = (off: number) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 15em; width: 15em;
min-width: 10em;
max-width: 15em;
padding-top: 2em;
} }
.nav > .button { .nav > .button {
background: none; background: none;
@@ -269,12 +294,13 @@ const set_offset = (off: number) => {
} }
.page { .page {
flex: 1 1 auto; flex: 1 1 auto;
overflow-x: hidden; max-width: 100vw;
max-width: 100%; min-width: 0; /* prevents overflow */
display: flex;
flex-direction: column;
} }
.username { .username {
flex: 1 1 auto; flex: 1 1 auto;
text-align: center;
margin: 3px; margin: 3px;
} }
@@ -290,6 +316,31 @@ const set_offset = (off: number) => {
margin: 5px; margin: 5px;
} }
.header {
flex: 0 0 auto;
display: flex;
flex-direction: row;
align-items: center;
justify-content: left;
background: var(--body_background);
border-bottom: 1px solid var(--separator);
position: sticky;
top: 0;
z-index: 8; /* below navigation, on top of most other things */
}
.menu_button {
flex: 0 0 auto;
background: none;
box-shadow: none;
}
.breadcrumbs {
flex: 1 1 auto;
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-items: center;
}
.spinner { .spinner {
position: fixed; position: fixed;
top: 10px; top: 10px;

View File

@@ -44,7 +44,7 @@ const toggle = (e: MouseEvent) => {
</script> </script>
<div class="title"> <div class="title">
<ToggleButton bind:on={expanded} action={toggle} icon_on="arrow_drop_down" icon_off="arrow_drop_up" highlight={false}/> <ToggleButton bind:on={expanded} action={toggle} icon_on="arrow_drop_down" icon_off="arrow_drop_up" highlight={false} flat/>
{#if !collapsed} {#if !collapsed}
{@render title()} {@render title()}

View File

@@ -11,6 +11,7 @@ import Appearance from "pages/Appearance.svelte";
import Footer from "layout/Footer.svelte"; import Footer from "layout/Footer.svelte";
import { current_page_store, type Tab } from "./RouterStore"; import { current_page_store, type Tab } from "./RouterStore";
import { get_user, type User } from "lib/PixeldrainAPI"; import { get_user, type User } from "lib/PixeldrainAPI";
import { breadcrumbs_store } from "./BreadcrumbStore";
let pages: Tab[] = [ let pages: Tab[] = [
{ {
@@ -37,6 +38,7 @@ let pages: Tab[] = [
title: "Filesystem", title: "Filesystem",
component: Filesystem, component: Filesystem,
footer: false, footer: false,
custom_breadcrumbs: true,
}, { }, {
path: "/admin", path: "/admin",
prefix: "/admin/", prefix: "/admin/",
@@ -107,6 +109,10 @@ const load_page = (pathname: string, history: boolean): boolean => {
window.history.pushState({}, window.document.title, pathname) window.history.pushState({}, window.document.title, pathname)
} }
if (current_page.custom_breadcrumbs !== true) {
breadcrumbs_store.set(breadcrumbs)
}
// The current_page_store updates all the listening pages for navigation // The current_page_store updates all the listening pages for navigation
// events. We first wait for a tick so that the current page gets unmounted // events. We first wait for a tick so that the current page gets unmounted
// before sending the event. That way a stale page will not get events which // before sending the event. That way a stale page will not get events which
@@ -144,6 +150,10 @@ const popstate = (e: PopStateEvent) => {
} }
</script> </script>
{#snippet breadcrumbs()}
{current_page.title}
{/snippet}
<svelte:document onclick={click}/> <svelte:document onclick={click}/>
<svelte:window onpopstate={popstate}/> <svelte:window onpopstate={popstate}/>

View File

@@ -8,5 +8,6 @@ export type Tab = {
component?: Component, component?: Component,
footer?: boolean, footer?: boolean,
login?: boolean, login?: boolean,
custom_breadcrumbs?: boolean,
}; };
export let current_page_store = writable({} as Tab); export let current_page_store = writable({} as Tab);

View File

@@ -10,7 +10,21 @@ let siblings: FSNode[] = $state([])
onMount(() => { onMount(() => {
return global_navigator.subscribe(async () => { return global_navigator.subscribe(async () => {
siblings = await global_navigator.get_siblings() const all_siblings = await global_navigator.get_siblings()
// Find the base
let base_idx = 0
for (let i = 0; i < all_siblings.length; i++) {
if (global_navigator.base.id === all_siblings[i].id) {
base_idx = i
break
}
}
siblings = all_siblings.slice(
Math.max(base_idx-50,0),
Math.min(base_idx+50, all_siblings.length),
)
}) })
}) })
</script> </script>
@@ -18,7 +32,7 @@ onMount(() => {
<MenuEntry id="tree_parents" collapsed={menu_collapsed}> <MenuEntry id="tree_parents" collapsed={menu_collapsed}>
{#snippet title()} {#snippet title()}
<div class="title">Parent directories</div> <div class="title">Parent directories</div>
<button title="Navigate up" onclick={() => global_navigator.navigate_up()}> <button title="Navigate up" onclick={() => global_navigator.navigate_up()} class="button flat">
<i class="icon">north</i> <i class="icon">north</i>
</button> </button>
{/snippet} {/snippet}
@@ -40,10 +54,10 @@ onMount(() => {
<MenuEntry id="tree_siblings" collapsed={menu_collapsed}> <MenuEntry id="tree_siblings" collapsed={menu_collapsed}>
{#snippet title()} {#snippet title()}
<div class="title">Siblings</div> <div class="title">Siblings</div>
<button title="Open previous sibling" onclick={() => global_navigator.open_sibling(-1)}> <button title="Open previous sibling" onclick={() => global_navigator.open_sibling(-1)} class="button flat">
<i class="icon">west</i> <i class="icon">west</i>
</button> </button>
<button title="Open next sibling" onclick={() => global_navigator.open_sibling(1)}> <button title="Open next sibling" onclick={() => global_navigator.open_sibling(1)} class="button flat">
<i class="icon">east</i> <i class="icon">east</i>
</button> </button>
{/snippet} {/snippet}
@@ -65,7 +79,6 @@ onMount(() => {
<style> <style>
.title { .title {
flex: 1 1 auto; flex: 1 1 auto;
text-align: center;
margin: 3px; margin: 3px;
} }
.row { .row {

View File

@@ -1,11 +1,13 @@
package webcontroller package webcontroller
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"fornaxian.tech/log" "fornaxian.tech/log"
"fornaxian.tech/pixeldrain_api_client/pixelapi"
"fornaxian.tech/util" "fornaxian.tech/util"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
) )
@@ -25,16 +27,19 @@ func (wc *WebController) serveDirectory(w http.ResponseWriter, r *http.Request,
node, err := td.PixelAPI.GetFilesystemPath(path) node, err := td.PixelAPI.GetFilesystemPath(path)
if err != nil { if err != nil {
if err.Error() == "not_found" || err.Error() == "path_not_found" { if apiErr, ok := errors.AsType[pixelapi.Error](err); ok {
switch apiErr.StatusCode {
case "not_found", "path_not_found":
wc.serveNotFound(w, r) wc.serveNotFound(w, r)
} else if err.Error() == "forbidden" { case "forbidden":
wc.serveForbidden(w, r) wc.serveForbidden(w, r)
} else if err.Error() == "authentication_required" { case "authentication_required":
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
} else if err.Error() == "unavailable_for_legal_reasons" { case "unavailable_for_legal_reasons":
wc.serveUnavailableForLegalReasons(w, r) wc.serveUnavailableForLegalReasons(w, r)
} else if err.Error() == "permission_denied" { case "permission_denied":
wc.serveForbidden(w, r) wc.serveForbidden(w, r)
}
} else { } else {
log.Error("Failed to get path: %s", err) log.Error("Failed to get path: %s", err)
wc.templates.Run(w, r, "500", td) wc.templates.Run(w, r, "500", td)

View File

@@ -262,7 +262,7 @@ func (s styleSheet) String() string {
s.BodyBackground.CSS(), s.BodyBackground.CSS(),
s.BodyText.CSS(), s.BodyText.CSS(),
s.Separator.CSS(), s.Separator.CSS(),
s.BodyColor.WithAlpha(0.7).CSS(), // shaded_background s.BodyColor.WithAlpha(0.9).CSS(), // shaded_background
s.CardColor.CSS(), s.CardColor.CSS(),
s.Chart1.CSS(), s.Chart1.CSS(),
s.Chart2.CSS(), s.Chart2.CSS(),

View File

@@ -33,7 +33,7 @@ type TemplateData struct {
Title string Title string
OGData ogData OGData ogData
Other interface{} Other any
URLQuery url.Values URLQuery url.Values
} }
@@ -232,15 +232,15 @@ func (tm *TemplateManager) pageNr(s string) (nr int) {
} }
return nr return nr
} }
func (tm *TemplateManager) add(a, b interface{}) float64 { return toFloat(a) + toFloat(b) } func (tm *TemplateManager) add(a, b any) float64 { return toFloat(a) + toFloat(b) }
func (tm *TemplateManager) sub(a, b interface{}) float64 { return toFloat(a) - toFloat(b) } func (tm *TemplateManager) sub(a, b any) float64 { return toFloat(a) - toFloat(b) }
func (tm *TemplateManager) mul(a, b interface{}) float64 { return toFloat(a) * toFloat(b) } func (tm *TemplateManager) mul(a, b any) float64 { return toFloat(a) * toFloat(b) }
func (tm *TemplateManager) div(a, b interface{}) float64 { return toFloat(a) / toFloat(b) } func (tm *TemplateManager) div(a, b any) float64 { return toFloat(a) / toFloat(b) }
func (tm *TemplateManager) formatData(i interface{}) string { func (tm *TemplateManager) formatData(i any) string {
return util.FormatData(detectInt(i)) return util.FormatData(detectInt(i))
} }
func (tm *TemplateManager) formatDataBits(i interface{}) string { func (tm *TemplateManager) formatDataBits(i any) string {
var size = detectInt(i) * 8 var size = detectInt(i) * 8
var sizef = float64(size) var sizef = float64(size)
@@ -302,7 +302,7 @@ func (tm *TemplateManager) noEscape(t string) template.HTML { return template.HT
func (tm *TemplateManager) noEscapeJS(t string) template.JS { return template.JS(t) } func (tm *TemplateManager) noEscapeJS(t string) template.JS { return template.JS(t) }
func (tm *TemplateManager) slashes() template.HTML { return template.HTML("//") } func (tm *TemplateManager) slashes() template.HTML { return template.HTML("//") }
func detectInt(i interface{}) int { func detectInt(i any) int {
switch v := i.(type) { switch v := i.(type) {
case int: case int:
return int(v) return int(v)
@@ -332,7 +332,7 @@ func detectInt(i interface{}) int {
panic(fmt.Sprintf("%v is not an int", i)) panic(fmt.Sprintf("%v is not an int", i))
} }
func toFloat(i interface{}) float64 { func toFloat(i any) float64 {
switch v := i.(type) { switch v := i.(type) {
case int: case int:
return float64(v) return float64(v)