UI overhaul, import fixes, fix user store

This commit is contained in:
2026-07-30 17:07:46 +02:00
parent 773149169b
commit 4b8bd11a4c
75 changed files with 1106 additions and 744 deletions

12
go.mod
View File

@@ -3,6 +3,7 @@ module fornaxian.tech/nova_web
go 1.26 go 1.26
replace ( replace (
fornaxian.tech/nova_server => ../nova_server
fornaxian.tech/pixeldrain_api_client => ../pixeldrain_api_client fornaxian.tech/pixeldrain_api_client => ../pixeldrain_api_client
fornaxian.tech/util => ../util fornaxian.tech/util => ../util
) )
@@ -10,7 +11,7 @@ replace (
require ( require (
fornaxian.tech/config v0.0.0-20211108212237-6133aed90586 fornaxian.tech/config v0.0.0-20211108212237-6133aed90586
fornaxian.tech/log v0.0.0-20211102185326-552e9b1f8640 fornaxian.tech/log v0.0.0-20211102185326-552e9b1f8640
fornaxian.tech/pixeldrain_api_client v0.0.0-20260124133150-a94861b84e11 fornaxian.tech/pixeldrain_api_client v0.0.0-20260701091325-dc9629676a79
fornaxian.tech/util v0.0.0-20260123171803-02d15be63d16 fornaxian.tech/util v0.0.0-20260123171803-02d15be63d16
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
github.com/russross/blackfriday/v2 v2.1.0 github.com/russross/blackfriday/v2 v2.1.0
@@ -18,8 +19,13 @@ require (
require ( require (
github.com/BurntSushi/toml v1.6.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
github.com/apache/cassandra-gocql-driver/v2 v2.1.1 // indirect github.com/apache/cassandra-gocql-driver/v2 v2.1.2 // indirect
github.com/golang/snappy v1.0.0 // indirect github.com/golang/snappy v1.0.0 // indirect
golang.org/x/crypto v0.51.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/stretchr/testify v1.11.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
) )

32
go.sum
View File

@@ -4,36 +4,36 @@ fornaxian.tech/log v0.0.0-20211102185326-552e9b1f8640 h1:UPDxJwLRCfh/cv80UMSanzm
fornaxian.tech/log v0.0.0-20211102185326-552e9b1f8640/go.mod h1:sN82qMToeHhP2u3ehvrcE8y1IudRZJAZO9yG5OBYblo= fornaxian.tech/log v0.0.0-20211102185326-552e9b1f8640/go.mod h1:sN82qMToeHhP2u3ehvrcE8y1IudRZJAZO9yG5OBYblo=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/apache/cassandra-gocql-driver/v2 v2.1.1 h1:DjmtmSRoUuIFswczA8LPBppRb8T9vxqNP5Txc/lQxhE= github.com/apache/cassandra-gocql-driver/v2 v2.1.2 h1:lu/p0Db2av18enHJvWJQoChLssI0P+AR06STq4VdvCc=
github.com/apache/cassandra-gocql-driver/v2 v2.1.1/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4= github.com/apache/cassandra-gocql-driver/v2 v2.1.2/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/pierrec/lz4/v4 v4.1.8 h1:ieHkV+i2BRzngO4Wd/3HGowuZStgq6QkPsD1eolNAO4= github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 410 KiB

View File

@@ -78,9 +78,7 @@ footer {
} }
footer { footer {
background-color: var(--shaded_background);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
border-top: 1px solid var(--separator);
} }
footer>.footer_content { footer>.footer_content {
@@ -137,6 +135,8 @@ pre>code {
.page_content { .page_content {
background: var(--body_background); background: var(--body_background);
text-align: center; text-align: center;
margin: 4px;
border-radius: 8px;
} }
@media (max-width: 1000px) { @media (max-width: 1000px) {
@@ -163,6 +163,14 @@ section {
clear: both; clear: both;
} }
.bubble {
background: var(--body_background);
margin: 4px;
border-radius: 8px;
border-top: 1px solid var(--separator);
border-left: 1px solid var(--separator);
}
/* Page contents */ /* Page contents */
.page_navigation a { .page_navigation a {
@@ -678,11 +686,11 @@ option:checked {
} }
::-webkit-scrollbar-corner { ::-webkit-scrollbar-corner {
background-color: var(--body_color); background-color: var(--background_color);
} }
::-webkit-scrollbar-button:single-button { ::-webkit-scrollbar-button:single-button {
background-color: var(--body_color); background-color: var(--background_color);
display: block; display: block;
border-style: solid; border-style: solid;
height: 10px; height: 10px;
@@ -692,5 +700,5 @@ option:checked {
/* Firefox Scrollbar */ /* Firefox Scrollbar */
* { * {
scrollbar-color: var(--scrollbar_foreground_color) var(--body_color); scrollbar-color: var(--scrollbar_foreground_color) var(--background_color);
} }

View File

@@ -24,7 +24,6 @@
{{ template "opengraph" .OGData }} {{ template "opengraph" .OGData }}
<script> <script>
window.user = {{.User}};
window.api_endpoint = '{{.APIEndpoint}}'; window.api_endpoint = '{{.APIEndpoint}}';
window.server_hostname = "{{.Hostname}}"; window.server_hostname = "{{.Hostname}}";
</script> </script>

View File

@@ -2494,9 +2494,9 @@
] ]
}, },
"node_modules/@sveltejs/acorn-typescript": { "node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.6", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"acorn": "^8.9.0" "acorn": "^8.9.0"
@@ -2569,6 +2569,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2635,9 +2641,9 @@
} }
}, },
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.2", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -2966,6 +2972,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/devalue": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz",
"integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==",
"license": "MIT"
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -3045,12 +3057,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/esrap": { "node_modules/esrap": {
"version": "2.1.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz",
"integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==", "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15" "@jridgewell/sourcemap-codec": "^1.4.15"
},
"peerDependencies": {
"@typescript-eslint/types": "^8.2.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/types": {
"optional": true
}
} }
}, },
"node_modules/estree-walker": { "node_modules/estree-walker": {
@@ -4037,21 +4057,23 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.39.11", "version": "5.56.8",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.39.11.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz",
"integrity": "sha512-8MxWVm2+3YwrFbPaxOlT1bbMi6OTenrAgks6soZfiaS8Fptk4EVyRIFhJc3RpO264EeSNwgjWAdki0ufg4zkGw==", "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5", "@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1", "acorn": "^8.12.1",
"aria-query": "^5.3.1", "aria-query": "5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.8.1",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.1.0", "esrap": "^2.2.12",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",

View File

@@ -33,7 +33,7 @@ const update_chart = async (timestamps: string[], metrics: {[key: string]: numbe
chart.data().datasets[0] = { chart.data().datasets[0] = {
label: "sum", label: "sum",
data: create_sum_dataset(metrics), data: create_sum_dataset(metrics),
borderWidth: 1.5, borderWidth: 1,
pointRadius: 0, pointRadius: 0,
borderColor: "#ff0000", borderColor: "#ff0000",
backgroundColor: "#ff0000", backgroundColor: "#ff0000",
@@ -41,7 +41,7 @@ const update_chart = async (timestamps: string[], metrics: {[key: string]: numbe
chart.data().datasets[1] = { chart.data().datasets[1] = {
label: "average", label: "average",
data: create_avg_dataset(metrics), data: create_avg_dataset(metrics),
borderWidth: 1.5, borderWidth: 1,
pointRadius: 0, pointRadius: 0,
borderColor: "#00ff00", borderColor: "#00ff00",
backgroundColor: "#00ff00", backgroundColor: "#00ff00",

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fs_encode_path } from "lib/FilesystemAPI.svelte"; import { fs_encode_path } from "lib/FilesystemAPI";
import { path_link, type FSNavigator } from "./FSNavigator"; import { path_link, type FSNavigator } from "./FSNavigator";
import FileMenu from "./filemanager/FileMenu.svelte"; import FileMenu from "./filemanager/FileMenu.svelte";
import EditWindow from "./edit_window/EditWindow.svelte"; import EditWindow from "./edit_window/EditWindow.svelte";

View File

@@ -3,7 +3,7 @@ import { run } from 'svelte/legacy';
import Chart from "util/Chart.svelte"; import Chart from "util/Chart.svelte";
import { formatDataVolume, formatDate, formatThousands } from "util/Formatting"; import { formatDataVolume, formatDate, formatThousands } from "util/Formatting";
import Modal from "util/Modal.svelte"; import Modal from "util/Modal.svelte";
import { fs_path_url, fs_share_hotlink_url, fs_share_url, fs_timeseries, type FSNode } from "lib/FilesystemAPI.svelte"; import { fs_path_url, fs_share_hotlink_url, fs_share_url, fs_timeseries, type FSNode } from "lib/FilesystemAPI";
import { color_by_name } from "util/Util"; import { color_by_name } from "util/Util";
import { tick } from "svelte"; import { tick } from "svelte";
import CopyButton from "layout/CopyButton.svelte"; import CopyButton from "layout/CopyButton.svelte";

View File

@@ -1,6 +1,6 @@
import { loading_finish, loading_start } from "lib/Loading"; import { loading_finish, loading_start } from "lib/Loading";
import { fs_get_node, fs_encode_path, fs_split_path } from "../lib/FilesystemAPI.svelte"; import { fs_get_node, fs_encode_path, fs_split_path } from "lib/FilesystemAPI";
import type { FSNode, FSPath, FSPermissions, FSContext } from "../lib/FilesystemAPI.svelte"; import type { FSNode, FSPath, FSPermissions, FSContext } from "lib/FilesystemAPI";
export class FSNavigator { export class FSNavigator {
// Parts of the raw API response // Parts of the raw API response

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { formatDataVolume, formatThousands } from "util/Formatting" import { formatDataVolume, formatThousands } from "util/Formatting"
import { fs_path_url } from "lib/FilesystemAPI.svelte"; import { fs_path_url } from "lib/FilesystemAPI";
import type { FSNavigator } from "./FSNavigator"; import type { FSNavigator } from "./FSNavigator";
let { let {

View File

@@ -35,15 +35,16 @@ const open = () => dialog.open(button.getBoundingClientRect())
<NovaLogo style="height: 1.6em; width: 1.6em;"/> <NovaLogo style="height: 1.6em; width: 1.6em;"/>
{/if} {/if}
<span class="button_username" class:hide_name> <span class="button_username" class:hide_name>
{$user.username === "" ? no_login_label : $user.username} {#if $user !== null}
{$user.username === "" ? no_login_label : $user.username}
{/if}
</span> </span>
</button> </button>
</div> </div>
<Dialog bind:this={dialog}> <Dialog bind:this={dialog}>
<div class="menu"> <div class="menu">
{#if $user.username !== undefined && $user.username !== ""} {#if $user !== null}
<Button link_href="/user" link_target={target} icon="dashboard" label="Dashboard" /> <Button link_href="/user" link_target={target} icon="dashboard" label="Dashboard" />
<div class="separator"></div> <div class="separator"></div>
@@ -63,12 +64,7 @@ const open = () => dialog.open(button.getBoundingClientRect())
</div> </div>
<div class="separator"></div> <div class="separator"></div>
{#if $user.subscription.filesystem_access} <Button link_href="/d/me" link_target={target} icon="folder" label="My Filesystem"/>
<Button link_href="/d/me" link_target={target} icon="folder" label="My Filesystem"/>
{:else}
<Button link_href="/#pro" link_target={target} icon="star" label="Get Premium"/>
{/if}
<Button link_href="/filesystem" link_target={target} icon="description" label="Filesystem Guide"/> <Button link_href="/filesystem" link_target={target} icon="description" label="Filesystem Guide"/>
<div class="separator"></div> <div class="separator"></div>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from "svelte"; import { onMount, tick } from "svelte";
import { fs_search, fs_encode_path, fs_thumbnail_url, FSNode } from "lib/FilesystemAPI.svelte"; import { fs_search, fs_encode_path, fs_thumbnail_url, FSNode } from "lib/FilesystemAPI";
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"; import Modal from "util/Modal.svelte";

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { FSNavigator } from "./FSNavigator"; import type { FSNavigator } from "./FSNavigator";
import { fs_node_icon, fs_share_hotlink_url, fs_share_url, fs_update, type FSNode, type FSPermissions } from "lib/FilesystemAPI.svelte"; import { fs_node_icon, fs_share_hotlink_url, fs_share_url, fs_update, type FSNode, type FSPermissions } from "lib/FilesystemAPI";
import { copy_text } from "util/Util"; import { copy_text } from "util/Util";
import CopyButton from "layout/CopyButton.svelte"; import CopyButton from "layout/CopyButton.svelte";
import Dialog from "layout/Dialog.svelte"; import Dialog from "layout/Dialog.svelte";

View File

@@ -3,7 +3,7 @@ import { copy_text } from "util/Util";
import FileStats from "./FileStats.svelte"; import FileStats from "./FileStats.svelte";
import type { FSNavigator } from "./FSNavigator"; import type { FSNavigator } from "./FSNavigator";
import EditWindow from "./edit_window/EditWindow.svelte"; import EditWindow from "./edit_window/EditWindow.svelte";
import { fs_share_url, path_is_shared } from "lib/FilesystemAPI.svelte"; import { fs_share_url, path_is_shared } from "lib/FilesystemAPI";
import ShareDialog from "./ShareDialog.svelte"; import ShareDialog from "./ShareDialog.svelte";
import { bookmark_add, bookmark_del, bookmarks_store, is_bookmark } from "lib/Bookmarks"; import { bookmark_add, bookmark_del, bookmarks_store, is_bookmark } from "lib/Bookmarks";
@@ -36,7 +36,7 @@ export const copy_link = () => {
} }
</script> </script>
<div class="toolbar"> <div class="toolbar bubble">
<FileStats nav={nav} bind:expanded={toolbar_expanded}/> <FileStats nav={nav} bind:expanded={toolbar_expanded}/>
<div class="grid" class:hide={!toolbar_expanded}> <div class="grid" class:hide={!toolbar_expanded}>
@@ -110,11 +110,10 @@ export const copy_link = () => {
overflow-x: hidden; overflow-x: hidden;
overflow-y: hidden; overflow-y: hidden;
transition: max-height 0.3s; transition: max-height 0.3s;
border-top: 1px solid var(--separator);
background: var(--shaded_background); background: var(--shaded_background);
position: sticky; position: sticky;
bottom: 0; bottom: 4px;
} }
.grid { .grid {
display: grid; display: grid;

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Button from "layout/Button.svelte"; import Button from "layout/Button.svelte";
import type { FSPermissions, NodeOptions } from "lib/FilesystemAPI.svelte"; import type { FSPermissions, NodeOptions } from "lib/FilesystemAPI";
import PermissionButton from "./PermissionButton.svelte"; import PermissionButton from "./PermissionButton.svelte";
let { let {
@@ -60,7 +60,6 @@ const del_password = (pass: string) => {
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>User permissions</legend> <legend>User permissions</legend>
<p> <p>

View File

@@ -2,7 +2,7 @@ import parse from "pure-color/parse";
import rgb2hsl from "pure-color/convert/rgb2hsl"; import rgb2hsl from "pure-color/convert/rgb2hsl";
import hsl2rgb from "pure-color/convert/hsl2rgb"; 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";
const text_contrast = 85 const text_contrast = 85
const body_alpha = 0.9 const body_alpha = 0.9

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import ThemePresets from "./ThemePresets.svelte"; import ThemePresets from "./ThemePresets.svelte";
import { fs_update, fs_node_type, type FSNode, type NodeOptions, type FSPermissions } from "lib/FilesystemAPI.svelte"; import { fs_update, fs_node_type, type FSNode, type NodeOptions, type FSPermissions } from "lib/FilesystemAPI";
import CustomBanner from "filesystem/viewers/CustomBanner.svelte"; import CustomBanner from "filesystem/viewers/CustomBanner.svelte";
import HelpButton from "layout/HelpButton.svelte"; import HelpButton from "layout/HelpButton.svelte";
import FilePicker from "filesystem/filemanager/FilePicker.svelte"; import FilePicker from "filesystem/filemanager/FilePicker.svelte";

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fs_rename, fs_update, type FSNode, type FSPermissions, type NodeOptions } from "lib/FilesystemAPI.svelte"; import { fs_rename, fs_update, type FSNode, type FSPermissions, type NodeOptions } from "lib/FilesystemAPI";
import Modal from "util/Modal.svelte"; import Modal from "util/Modal.svelte";
import BrandingOptions from "./BrandingOptions.svelte"; import BrandingOptions from "./BrandingOptions.svelte";
import { branding_from_props } from "./Branding"; import { branding_from_props } from "./Branding";

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Button from "layout/Button.svelte"; import Button from "layout/Button.svelte";
import { fs_trash, type FSNode } from "lib/FilesystemAPI.svelte"; import { fs_trash, type FSNode } from "lib/FilesystemAPI";
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";

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import ToggleButton from "layout/ToggleButton.svelte"; import ToggleButton from "layout/ToggleButton.svelte";
import type { FSPermissions } from "lib/FilesystemAPI.svelte"; import type { FSPermissions } from "lib/FilesystemAPI";
let { let {
permissions = $bindable() permissions = $bindable()

View File

@@ -3,7 +3,7 @@ import { run } from 'svelte/legacy';
import { domain_url } from "util/Util"; import { domain_url } from "util/Util";
import CopyButton from "layout/CopyButton.svelte"; import CopyButton from "layout/CopyButton.svelte";
import { formatDate } from "util/Formatting"; import { formatDate } from "util/Formatting";
import { type FSNode, type NodeOptions } from "lib/FilesystemAPI.svelte"; import { type FSNode, type NodeOptions } from "lib/FilesystemAPI";
import AccessControl from "./AccessControl.svelte"; import AccessControl from "./AccessControl.svelte";
let { let {

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { FSNodeProperties } from "lib/FilesystemAPI.svelte"; import type { FSNodeProperties } from "lib/FilesystemAPI";
let { let {
properties = $bindable({} as FSNodeProperties) properties = $bindable({} as FSNodeProperties)
@@ -88,7 +88,7 @@ const themes = [
brand_card_color: "#313244", brand_card_color: "#313244",
}, { }, {
name: "Nova", name: "Nova",
brand_input_color: "#170025", brand_input_color: "#28033f",
brand_highlight_color: "#57e389", brand_highlight_color: "#57e389",
brand_danger_color: "#ed333b", brand_danger_color: "#ed333b",
brand_background_color: "#0d0015", brand_background_color: "#0d0015",

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte" import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI"
import type { FSNavigator } from "filesystem/FSNavigator"; import type { FSNavigator } from "filesystem/FSNavigator";
import { FileAction, type FileActionHandler } from "./FileManagerLib"; import { FileAction, type FileActionHandler } from "./FileManagerLib";

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { fs_mkdir } from "lib/FilesystemAPI.svelte"; import { fs_mkdir } from "lib/FilesystemAPI";
import Button from "layout/Button.svelte"; import Button from "layout/Button.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";

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fs_rename, fs_trash, FSNode } from "lib/FilesystemAPI.svelte" import { fs_rename, fs_trash, FSNode } from "lib/FilesystemAPI"
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"
@@ -305,7 +305,7 @@ onMount(() => {
shadow: "var(--highlight_color) 0 0 10px 2px inset", shadow: "var(--highlight_color) 0 0 10px 2px inset",
}} }}
> >
<div class="width_container"> <div class="width_container bubble">
{#if mode === "viewing"} {#if mode === "viewing"}
<div class="toolbar"> <div class="toolbar">
<div class="toolbar_left"> <div class="toolbar_left">
@@ -430,11 +430,9 @@ onMount(() => {
position: sticky; position: sticky;
top: 0; top: 0;
display: block; display: block;
width: 100%; margin-top: 0;
margin: auto;
padding: 0; padding: 0;
background: var(--body_background); background: var(--body_background);
/* backdrop-filter: blur(4px); */
} }
.toolbar { .toolbar {
display: flex; display: flex;

View File

@@ -4,7 +4,7 @@ import type { FSNavigator } from "filesystem/FSNavigator";
import Button from "layout/Button.svelte"; import Button from "layout/Button.svelte";
import Dialog from "layout/Dialog.svelte"; import Dialog from "layout/Dialog.svelte";
import { bookmark_add, bookmark_del, bookmarks_store, is_bookmark } from "lib/Bookmarks"; import { bookmark_add, bookmark_del, bookmarks_store, is_bookmark } from "lib/Bookmarks";
import { fs_trash, type FSNode } from "lib/FilesystemAPI.svelte"; import { fs_trash, type FSNode } from "lib/FilesystemAPI";
import { loading_finish, loading_start } from "lib/Loading"; import { loading_finish, loading_start } from "lib/Loading";
import { tick } from "svelte"; import { tick } from "svelte";

View File

@@ -5,7 +5,7 @@ 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 { FSNavigator, path_link } from "filesystem/FSNavigator"; import { FSNavigator, path_link } from "filesystem/FSNavigator";
import { fs_encode_path, type FSNode } from "lib/FilesystemAPI.svelte"; import { fs_encode_path, type FSNode } from "lib/FilesystemAPI";
import { FileAction, type FileActionHandler } from "./FileManagerLib"; import { FileAction, type FileActionHandler } from "./FileManagerLib";
let nav = $state(new FSNavigator(false)) let nav = $state(new FSNavigator(false))

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fs_node_icon, fs_node_type, fs_encode_path } from "lib/FilesystemAPI.svelte"; import { fs_node_icon, fs_node_type, fs_encode_path } from "lib/FilesystemAPI";
import type { FSNavigator } from "filesystem/FSNavigator"; import type { FSNavigator } from "filesystem/FSNavigator";
import { FileAction, type FileActionHandler } from "./FileManagerLib"; import { FileAction, type FileActionHandler } from "./FileManagerLib";

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { formatDataVolume } from "util/Formatting"; import { formatDataVolume } from "util/Formatting";
import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte" import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI"
import type { FSNavigator } from "filesystem/FSNavigator"; import type { FSNavigator } from "filesystem/FSNavigator";
import SortButton from "layout/SortButton.svelte"; import SortButton from "layout/SortButton.svelte";
import { FileAction, type FileActionHandler } from "./FileManagerLib"; import { FileAction, type FileActionHandler } from "./FileManagerLib";

View File

@@ -0,0 +1,260 @@
<script lang="ts">
import { formatDataVolume, formatDate } from "util/Formatting";
import { fs_check_response, fs_encode_path, fs_get_trash, fs_node_icon, fs_path_url, type TrashEntry } from "lib/FilesystemAPI";
import { loading_finish, loading_start } from "lib/Loading";
import { onMount } from "svelte";
let { root_path = "/me" }: { root_path?: string } = $props();
let entries: TrashEntry[] = $state([]);
let error: string = $state("");
let loaded = $state(false);
const load = async () => {
loaded = false;
entries = [];
try {
loading_start();
entries = await fs_get_trash(root_path)
error = "";
} catch (err: any) {
error = err?.message ?? JSON.stringify(err);
} finally {
loaded = true;
loading_finish();
}
};
const delete_entry = async (entry: TrashEntry) => {
if (!confirm(`Permanently delete "${entry.node.name}"?`)) return;
try {
loading_start();
await fs_check_response(
await fetch(fs_path_url(entry.node.path) + "?trash", { method: "DELETE" })
);
entries = entries.filter(e => e.node.id !== entry.node.id);
} catch (err: any) {
alert(err?.message ?? JSON.stringify(err));
} finally {
loading_finish();
}
};
const restore_entry = async (entry: TrashEntry) => {
if (!entry.original_path) {
alert("Original path is not known, cannot restore this file.");
return;
}
try {
loading_start();
const form = new FormData();
form.append("action", "rename");
form.append("target", "/me" + entry.original_path);
await fs_check_response(
await fetch(fs_path_url(entry.node.path), { method: "POST", body: form })
);
entries = entries.filter(e => e.node.id !== entry.node.id);
} catch (err: any) {
alert(err?.message ?? JSON.stringify(err));
} finally {
loading_finish();
}
};
const empty_trash = async () => {
if (!confirm("Permanently delete all items in the trash?")) return;
try {
loading_start();
await fs_check_response(
await fetch(fs_path_url(root_path) + "?empty_trash", { method: "DELETE" })
);
entries = [];
} catch (err: any) {
alert(err?.message ?? JSON.stringify(err));
} finally {
loading_finish();
}
};
onMount(load);
</script>
<div class="trash_container">
<div class="header">
<h2 class="title"><i class="icon">delete</i> Trash</h2>
<div class="actions">
<button onclick={load}>
<i class="icon">refresh</i>
<span>Refresh</span>
</button>
{#if entries.length > 0}
<button class="danger" onclick={empty_trash}>
<i class="icon">delete_forever</i>
<span>Empty trash</span>
</button>
{/if}
</div>
</div>
{#if error}
<p class="error">{error}</p>
{:else if loaded && entries.length === 0}
<p class="empty">The trash is empty.</p>
{:else if entries.length > 0}
<table class="trash_table">
<thead>
<tr>
<td></td>
<td>Name</td>
<td class="hide_small">Original location</td>
<td class="hide_small">Deleted</td>
<td class="hide_small">Size</td>
<td></td>
</tr>
</thead>
<tbody>
{#each entries as entry (entry.node.id)}
<tr class="entry">
<td class="icon_cell">
<img
src={fs_node_icon(entry.node, 32, 32)}
class="node_icon"
alt="icon"
/>
</td>
<td class="name_cell">
<a class="entry_link" href={"/d" + fs_encode_path(entry.node.path)}>
{entry.node.name}
</a>
</td>
<td class="hide_small original_path">
{entry.original_path || "—"}
</td>
<td class="hide_small date_cell">
{entry.deletion_date ? formatDate(entry.deletion_date) : "—"}
</td>
<td class="hide_small size_cell">
{#if entry.node.type === "file"}
{formatDataVolume(entry.node.file_size, 3)}
{/if}
</td>
<td class="button_cell">
<div class="row_actions">
<button title="Restore to original location" onclick={() => restore_entry(entry)}>
<i class="icon">restore_from_trash</i>
</button>
<button class="danger" title="Delete permanently" onclick={() => delete_entry(entry)}>
<i class="icon">delete_forever</i>
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<style>
.trash_container {
max-width: 1200px;
margin: auto;
padding: 1em;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5em;
margin-bottom: 1em;
}
.title {
display: flex;
align-items: center;
gap: 0.3em;
margin: 0;
font-size: 1.2em;
border: none;
}
.actions {
display: flex;
gap: 0.5em;
}
.error {
color: var(--danger_color, #c0392b);
}
.empty {
color: var(--body_text_color);
opacity: 0.6;
}
.trash_table {
width: 100%;
border-collapse: collapse;
background: var(--body_background);
}
.trash_table td {
padding: 0;
}
.entry {
cursor: default;
border-top: 1px solid var(--separator);
}
.entry:hover {
background: var(--input_hover_background);
}
.icon_cell {
width: 2.5em;
padding: 0.3em 0.3em 0.3em 0.5em;
}
.node_icon {
display: block;
width: 2em;
height: 2em;
object-fit: contain;
}
.name_cell {
padding: 0.3em 0.5em;
word-break: break-all;
}
.entry_link {
color: var(--body_text_color);
text-decoration: none;
}
.entry_link:hover {
text-decoration: underline;
}
.original_path {
padding: 0.3em 0.5em;
color: var(--body_text_color);
opacity: 0.7;
font-size: 0.85em;
word-break: break-all;
}
.date_cell {
padding: 0.3em 0.5em;
white-space: nowrap;
font-size: 0.85em;
}
.size_cell {
padding: 0.3em 0.5em;
white-space: nowrap;
font-size: 0.85em;
text-align: right;
}
.button_cell {
padding: 0.3em 0.5em;
}
.row_actions {
display: flex;
gap: 0.25em;
}
.danger {
color: var(--danger_color, #c0392b);
}
@media (max-width: 600px) {
.hide_small {
display: none;
}
}
</style>

View File

@@ -9,7 +9,7 @@
// //
// on_error is called when the upload has failed. The parameters are the error // on_error is called when the upload has failed. The parameters are the error
import { fs_path_url, type GenericResponse } from "lib/FilesystemAPI.svelte" import { fs_path_url, type GenericResponse } from "lib/FilesystemAPI"
// code and an error message // code and an error message
export const upload_file = ( export const upload_file = (

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { fs_path_url, fs_encode_path, fs_node_icon, FSNode } from "lib/FilesystemAPI.svelte" import { fs_path_url, fs_encode_path, fs_node_icon, FSNode } from "lib/FilesystemAPI"
import TextBlock from "layout/TextBlock.svelte" import TextBlock from "layout/TextBlock.svelte"
import type { FSNavigator } from 'filesystem/FSNavigator'; import type { FSNavigator } from 'filesystem/FSNavigator';
@@ -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="auto"> <TextBlock>
<audio <audio
bind:this={player} bind:this={player}
class="player" class="player"
@@ -111,6 +111,7 @@ onMount(() => {
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
min-height: 100%; min-height: 100%;
border-radius: 8px;
} }
.player { .player {
width: 100%; width: 100%;

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { FSNode } from 'lib/FilesystemAPI.svelte'; import type { FSNode } from 'lib/FilesystemAPI';
import { run } from 'svelte/legacy'; import { run } from 'svelte/legacy';
let { path = [] }: {path: FSNode[]} = $props(); let { path = [] }: {path: FSNode[]} = $props();

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import IconBlock from "layout/IconBlock.svelte"; import IconBlock from "layout/IconBlock.svelte";
import { fs_thumbnail_url, FSNode } from "lib/FilesystemAPI.svelte"; import { fs_thumbnail_url, FSNode } from "lib/FilesystemAPI";
import TextBlock from "layout/TextBlock.svelte" import TextBlock from "layout/TextBlock.svelte"
import { formatDataVolume, formatDate } from "util/Formatting"; import { formatDataVolume, formatDate } from "util/Formatting";
@@ -17,12 +17,24 @@ let {
{@render children?.()} {@render children?.()}
<h1>{node.name}</h1>
<IconBlock icon_href={fs_thumbnail_url(node.path, 256, 256)}> <IconBlock icon_href={fs_thumbnail_url(node.path, 256, 256)}>
Type: {node.file_type}<br/> {node.name}<br/>
Size: {formatDataVolume(node.file_size, 3)}<br/> <table>
Upload date: {formatDate(node.created, true, true, false)} <tbody>
<tr>
<td>Type</td>
<td>{node.file_type}</td>
</tr>
<tr>
<td>Size</td>
<td>{formatDataVolume(node.file_size, 3)}</td>
</tr>
<tr>
<td>Upload date</td>
<td>{formatDate(node.created, true, true, false)}</td>
</tr>
</tbody>
</table>
<hr/> <hr/>
<button class="button_highlight" onclick={() => node.download()}> <button class="button_highlight" onclick={() => node.download()}>
<i class="icon">download</i> <i class="icon">download</i>
@@ -79,9 +91,12 @@ let {
{/if} {/if}
<style> <style>
h1 { table {
text-shadow: 1px 1px 3px var(--shadow_color); width: auto;
line-break: anywhere; }
td {
padding: 0;
padding-right: 1em;
} }
.icon { .icon {
display: inline-block; display: inline-block;

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from "svelte"; import { onMount, tick } from "svelte";
import Spinner from "util/Spinner.svelte"; import Spinner from "util/Spinner.svelte";
import { fs_node_type } from "lib/FilesystemAPI.svelte"; import { fs_node_type } from "lib/FilesystemAPI";
import FileManager from "filesystem/filemanager/FileManager.svelte"; import FileManager from "filesystem/filemanager/FileManager.svelte";
import TrashBin from "filesystem/filemanager/TrashBin.svelte";
import Audio from "./Audio.svelte"; import Audio from "./Audio.svelte";
import File from "./File.svelte"; import File from "./File.svelte";
import Image from "./Image.svelte"; import Image from "./Image.svelte";
@@ -88,6 +89,8 @@ export const seek = (delta: number) => {
<div class="center"> <div class="center">
<Spinner/> <Spinner/>
</div> </div>
{:else if viewer_type === "dir" && $nav.base.name === ".Trash"}
<TrashBin root_path={$nav.path[$nav.path.length - 2].path}/>
{:else if viewer_type === "dir"} {:else if viewer_type === "dir"}
<FileManager nav={nav} upload_widget={upload_widget} edit_window={edit_window} search_bar={search_bar}> <FileManager nav={nav} upload_widget={upload_widget} edit_window={edit_window} search_bar={search_bar}>
<CustomBanner path={$nav.path}/> <CustomBanner path={$nav.path}/>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { swipe_nav } from "lib/SwipeNavigate"; import { swipe_nav } from "lib/SwipeNavigate";
import { fs_path_url } from "lib/FilesystemAPI.svelte"; import { fs_path_url } from "lib/FilesystemAPI";
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";

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fs_path_url, FSNode } from "lib/FilesystemAPI.svelte"; import { fs_path_url, FSNode } from "lib/FilesystemAPI";
let { node }: { node: FSNode } = $props(); let { node }: { node: FSNode } = $props();
</script> </script>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick } from "svelte";
import { fs_path_url, type FSNode } from "lib/FilesystemAPI.svelte"; import { fs_path_url, type FSNode } from "lib/FilesystemAPI";
let { let {
node, node,

View File

@@ -18,7 +18,7 @@ import { formatDate } from "util/Formatting"
import TorrentItem from "./TorrentItem.svelte" import TorrentItem from "./TorrentItem.svelte"
import IconBlock from "layout/IconBlock.svelte"; import IconBlock from "layout/IconBlock.svelte";
import TextBlock from "layout/TextBlock.svelte" import TextBlock from "layout/TextBlock.svelte"
import { fs_node_icon, fs_path_url, FSNode } from "lib/FilesystemAPI.svelte"; import { fs_node_icon, fs_path_url, FSNode } from "lib/FilesystemAPI";
import CopyButton from "layout/CopyButton.svelte"; import CopyButton from "layout/CopyButton.svelte";
import { loading_finish, loading_start } from "lib/Loading"; import { loading_finish, loading_start } from "lib/Loading";

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from "svelte"; import { onMount, tick } from "svelte";
import { video_position } from "lib/VideoPosition"; import { video_position } from "lib/VideoPosition";
import { fs_path_url, FSNode } from "lib/FilesystemAPI.svelte"; import { fs_path_url, FSNode } from "lib/FilesystemAPI";
let { let {
node, node,

View File

@@ -12,7 +12,7 @@ import { formatDataVolume, formatDate } from "util/Formatting"
import ZipItem from "filesystem/viewers/ZipItem.svelte"; import ZipItem from "filesystem/viewers/ZipItem.svelte";
import IconBlock from "layout/IconBlock.svelte"; import IconBlock from "layout/IconBlock.svelte";
import TextBlock from "layout/TextBlock.svelte" import TextBlock from "layout/TextBlock.svelte"
import { fs_node_icon, fs_path_url, FSNode } from "lib/FilesystemAPI.svelte"; import { fs_node_icon, fs_path_url, FSNode } from "lib/FilesystemAPI";
import { loading_finish, loading_start } from "lib/Loading"; import { loading_finish, loading_start } from "lib/Loading";
let { let {
@@ -26,7 +26,7 @@ let {
let status = $state("loading") let status = $state("loading")
let zip: ZipEntry = $state({size: 0} as ZipEntry) let zip: ZipEntry = $state({size: 0} as ZipEntry)
let uncomp_size = 0 let uncomp_size = $state(0)
let comp_ratio = $state(0) let comp_ratio = $state(0)
let archive_type = $state("") let archive_type = $state("")
let truncated = $state(false) let truncated = $state(false)
@@ -97,8 +97,6 @@ const recursive_size = (file: ZipEntry) => {
{@render children?.()} {@render children?.()}
<h1>{node.name}</h1>
<IconBlock icon_href={fs_node_icon(node, 256, 256)}> <IconBlock icon_href={fs_node_icon(node, 256, 256)}>
{#if archive_type === "7z"} {#if archive_type === "7z"}
This is a 7-zip archive. You will need This is a 7-zip archive. You will need
@@ -106,12 +104,26 @@ const recursive_size = (file: ZipEntry) => {
extract it<br/> extract it<br/>
{/if} {/if}
Compressed size: {formatDataVolume(node.file_size, 3)}<br/> {node.name}<br/>
{#if !truncated} <table>
Uncompressed size: {formatDataVolume(zip.size, 3)} (Ratio: {comp_ratio.toFixed(2)}x)<br/> <tbody>
{/if} <tr>
Uploaded on: {formatDate(node.created, true, true, true)} <td>Compressed size</td>
<br/> <td>{formatDataVolume(node.file_size, 3)}</td>
</tr>
{#if !truncated}
<tr>
<td>Uncompressed size</td>
<td>{formatDataVolume(uncomp_size, 3)} (Ratio: {comp_ratio.toFixed(2)}x)</td>
</tr>
{/if}
<tr>
<td>Upload date</td>
<td>{formatDate(node.created, true, true, true)}</td>
</tr>
</tbody>
</table>
<hr/>
<button class="button_highlight" onclick={() => node.download()}> <button class="button_highlight" onclick={() => node.download()}>
<i class="icon">download</i> <i class="icon">download</i>
<span>Download</span> <span>Download</span>
@@ -140,8 +152,11 @@ const recursive_size = (file: ZipEntry) => {
{/if} {/if}
<style> <style>
h1 { table {
text-shadow: 1px 1px 3px var(--shadow_color); width: auto;
line-break: anywhere; }
td {
padding: 0;
padding-right: 1em;
} }
</style> </style>

View File

@@ -1,4 +1,6 @@
<script> <script lang="ts">
import { get_endpoint } from "lib/NovaAPI";
import { user } from "lib/UserStore";
import { onMount } from "svelte"; import { onMount } from "svelte";
import Expandable from "util/Expandable.svelte"; import Expandable from "util/Expandable.svelte";
import { formatDate } from "util/Formatting"; import { formatDate } from "util/Formatting";
@@ -7,9 +9,9 @@ let result = $state(null);
onMount(async () => { onMount(async () => {
try { try {
const resp = await fetch(window.api_endpoint+"/misc/ip_address_reputation"); const resp = await fetch(get_endpoint()+"/misc/ip_address_reputation");
if(resp.status >= 400) { if(resp.status >= 400) {
throw new Error(resp.text()); throw new Error(await resp.text());
} }
result = await resp.json() result = await resp.json()
} catch (err) { } catch (err) {
@@ -66,7 +68,7 @@ onMount(async () => {
support@nova.storage. Please do not mail unless you have a support@nova.storage. Please do not mail unless you have a
good reason. If you do not provide a valid reason why the ban good reason. If you do not provide a valid reason why the ban
should be reversed your e-mail will be ignored. And do not should be reversed your e-mail will be ignored. And do not
forget to put your username ({window.user.username}) in the forget to put your username ({$user === null ? "" : $user.username}) in the
e-mail. e-mail.
</p> </p>
</Expandable> </Expandable>

View File

@@ -1,7 +1,8 @@
<script> <script>
import Tooltip from "layout/Tooltip.svelte"; import Tooltip from "layout/Tooltip.svelte";
import Euro from "util/Euro.svelte"; import Euro from "util/Euro.svelte";
import OtherPlans from "./OtherPlans.svelte"; import OtherPlans from "./SubscriptionPlans.svelte";
import { user } from "lib/UserStore";
</script> </script>
<section> <section>
@@ -222,7 +223,7 @@ import OtherPlans from "./OtherPlans.svelte";
Free Free
</div> </div>
<div class="bottom_row pro_feat"> <div class="bottom_row pro_feat">
{#if window.user.subscription.id === "patreon_1"} {#if $user !== null && $user.subscription.id === "patreon_1"}
You have this plan<br/> You have this plan<br/>
Thank you for supporting Nova! Thank you for supporting Nova!
{:else} {:else}
@@ -240,7 +241,7 @@ import OtherPlans from "./OtherPlans.svelte";
{/if} {/if}
</div> </div>
<div class="bottom_row prepaid_feat"> <div class="bottom_row prepaid_feat">
{#if window.user.username === ""} {#if $user === null}
<!-- User is not logged in --> <!-- User is not logged in -->
Account required<br/> Account required<br/>
<a href="/login?redirect=/user/prepaid/deposit" class="button button_highlight round"> <a href="/login?redirect=/user/prepaid/deposit" class="button button_highlight round">
@@ -257,16 +258,16 @@ import OtherPlans from "./OtherPlans.svelte";
No recurring payments No recurring payments
{:else} {:else}
<!-- User is logged in --> <!-- User is logged in -->
{#if window.user.subscription.type === ""} {#if $user.subscription.type === ""}
<a href="/user/prepaid/deposit#deposit" class="button button_highlight round"> <a href="/user/prepaid/deposit#deposit" class="button button_highlight round">
Deposit credit Deposit credit
</a> </a>
to activate Prepaid to activate Prepaid
{:else if window.user.subscription.type === "patreon"} {:else if $user.subscription.type === "patreon"}
Patreon subscription active. Prepaid cannot be activated Patreon subscription active. Prepaid cannot be activated
{:else if window.user.subscription.type === "prepaid"} {:else if $user.subscription.type === "prepaid"}
Prepaid subscription is active<br/> Prepaid subscription is active<br/>
Current balance <Euro amount={window.user.balance_micro_eur}/> Current balance <Euro amount={$user.balance_micro_eur}/>
<br/> <br/>
<a href="/user/prepaid/deposit#deposit" class="button button_highlight round"> <a href="/user/prepaid/deposit#deposit" class="button button_highlight round">
Top up my credit Top up my credit

View File

@@ -1,28 +1,28 @@
<script> <script lang="ts">
import Checkout from "layout/checkout/Checkout.svelte"; import Checkout from "layout/checkout/Checkout.svelte";
import { user } from "lib/UserStore";
import LoginRegister from "login/LoginRegister.svelte"; import LoginRegister from "login/LoginRegister.svelte";
import Euro from "util/Euro.svelte"; import Euro from "util/Euro.svelte";
</script> </script>
{#if window.user.username !== ""} {#if $user !== null}
<div class="page_content"> <div class="page_content">
<section> <section>
{#if window.user.subscription.type === "patreon"} {#if $user.subscription.type === "patreon"}
<p> <p>
You already have a Patreon subscription active. You cannot use You already have a Patreon subscription active. You cannot use
Prepaid while that subscription is active. Prepaid while that subscription is active.
</p> </p>
{:else if $user.subscription.type === "prepaid"}
{:else if window.user.subscription.type === "prepaid"}
<p> <p>
You already have a Prepaid subscription active. Your account You already have a Prepaid subscription active. Your account
balance is <Euro amount={window.user.balance_micro_eur}/>. Use balance is <Euro amount={$user.balance_micro_eur}/>. Use
the form below to top up your balance. the form below to top up your balance.
</p> </p>
<Checkout/> <Checkout/>
{:else} {:else}
<p> <p>
You are currently logged in as '{window.user.username}'. Use the You are currently logged in as '{$user.username}'. Use the
form below to activate prepaid on this account. form below to activate prepaid on this account.
</p> </p>
<Checkout/> <Checkout/>

View File

@@ -3,9 +3,9 @@ import Menu from "filesystem/Menu.svelte";
import Footer from "layout/Footer.svelte"; import Footer from "layout/Footer.svelte";
import { drop_target } from "lib/DropTarget"; import { drop_target } from "lib/DropTarget";
import AddressReputation from "./AddressReputation.svelte"; import AddressReputation from "./AddressReputation.svelte";
import FeatureTable from "./FeatureTable.svelte";
import GetStarted from "./GetStarted.svelte"; import GetStarted from "./GetStarted.svelte";
import Pricing from "./Pricing.svelte"; import Pricing from "./Pricing.svelte";
import SubscriptionPlans from "./SubscriptionPlans.svelte";
let upload_widget let upload_widget
</script> </script>
@@ -28,26 +28,6 @@ let upload_widget
content delivery. We will store and serve your files at an extremely content delivery. We will store and serve your files at an extremely
competitive rate. competitive rate.
</p> </p>
<div class="prices">
<div>
<div>Storage pricing</div>
<div>€ 4 / TB / month</div>
</div>
<div>
<div>Egress pricing</div>
<div>€ 1 / TB</div>
</div>
</div>
<h2>What Nova is good at</h2>
<ul>
<li>
Serving large files to millions of people worldwide
</li>
<li>
Storing files for less money than all the competition
</li>
</ul>
<Pricing/>
</section> </section>
</div> </div>
@@ -107,6 +87,11 @@ let upload_widget
</section> </section>
</div> </div>
<header>
<h1>Subscription plans</h1>
</header>
<SubscriptionPlans/>
<header> <header>
<h1>Get started</h1> <h1>Get started</h1>
</header> </header>

View File

@@ -1,146 +0,0 @@
<div class="feat_table">
<div>
<div class="cell_background" style="background-image: url('/res/img/benefit_2.webp');">
Persistence<br/>
{#if window.user.subscription.id === "patreon_2"}
You have this plan<br/>
Thank you for supporting Nova!
{:else}
<a href="https://www.patreon.com/join/nova/checkout?rid=5291482" class="button button_highlight round">
€ 8
</a>
/ month
{/if}
</div>
<div class="feat_pro features_cell round_tr">
<div><span class="text_highlight">100 GB</span> max file size</div>
<div><span class="text_highlight">Files never expire</span></div>
<div><span class="text_highlight">8 TB</span> transfer limit</div>
<div><span class="text_highlight">4 TB</span> storage space</div>
<div>
<span class="text_highlight">File viewer
branding</span>: Set a custom theme and header,
footer and background images on the download pages
for your files
</div>
</div>
</div>
<div>
<div class="cell_background" style="background-image: url('/res/img/benefit_3.webp');">
Tenacity<br/>
{#if window.user.subscription.id === "patreon_3"}
You have this plan<br/>
Thank you for supporting Nova!
{:else}
<a href="https://www.patreon.com/join/nova/checkout?rid=5291516" class="button button_highlight round">
€ 16
</a>
/ month
{/if}
</div>
<div class="feat_pro features_cell">
<div><span class="text_highlight">100 GB</span> max file size</div>
<div><span class="text_highlight">Files never expire</span></div>
<div><span class="text_highlight">16 TB</span> transfer limit</div>
<div><span class="text_highlight">8 TB</span> storage space</div>
<div><span class="text_highlight">File viewer branding</span></div>
</div>
</div>
<div>
<div class="cell_background" style="background-image: url('/res/img/benefit_4.webp');">
Eternity<br/>
{#if window.user.subscription.id === "patreon_4"}
You have this plan<br/>
Thank you for supporting Nova!
{:else}
<a href="https://www.patreon.com/join/nova/checkout?rid=5291528" class="button button_highlight round">
€ 32
</a>
/ month
{/if}
</div>
<div class="feat_pro features_cell">
<div><span class="text_highlight">100 GB</span> max file size</div>
<div><span class="text_highlight">Files never expire</span></div>
<div><span class="text_highlight">32 TB</span> transfer limit</div>
<div><span class="text_highlight">16 TB</span> storage space</div>
<div><span class="text_highlight">File viewer branding</span></div>
</div>
</div>
<div>
<div class="cell_background" style="background-image: url('/res/img/benefit_6.webp');">
Infinity<br/>
{#if window.user.subscription.id === "patreon_6"}
You have this plan<br/>
Thank you for supporting Nova!
{:else}
<a href="https://www.patreon.com/join/nova/checkout?rid=6573749" class="button button_highlight round">
€ 64
</a>
/ month
{/if}
</div>
<div class="feat_pro features_cell round_br">
<div><span class="text_highlight">100 GB</span> max file size</div>
<div><span class="text_highlight">Files never expire</span></div>
<div><span class="text_highlight">64 TB</span> transfer limit</div>
<div><span class="text_highlight">32 TB</span> storage space</div>
<div><span class="text_highlight">File viewer branding</span></div>
</div>
</div>
</div>
<style>
.feat_table {
display: flex;
flex-direction: column;
}
.feat_table > div {
display: flex;
flex-direction: row;
min-height: 8em;
}
.feat_table > div > div:first-child {
flex: 0 0 20%;
max-width: 20%;
}
.feat_table > div > div {
flex: 1 1 0;
margin: 0.2em;
padding: 0.5em;
text-align: center;
word-wrap: break-word;
hyphens: auto;
}
.feat_table > div > .feat_pro {
background-color: var(--card_color);
border: 1px solid var(--highlight_color);
}
.text_highlight {
font-weight: bold;
}
.feat_table > div > .cell_background {
flex: 0 0 33%;
min-width: 33%;
border-top-left-radius: 0.5em;
border-bottom-left-radius: 0.5em;
background-position: center;
background-size: cover;
text-align: left;
font-size: 1.2em;
color: #ffffff;
text-shadow: 1px 1px 2px #000000;
}
.feat_table > div > div.round_tr { border-top-right-radius: 0.5em; }
.feat_table > div > div.round_br { border-bottom-right-radius: 0.5em; }
.features_cell {
display: flex;
flex-wrap: wrap;
}
.features_cell > div {
flex: 1 1 50%;
min-width: 220px;
}
</style>

View File

@@ -0,0 +1,226 @@
<script lang="ts">
import Euro from "util/Euro.svelte";
import { formatDataVolume } from "util/Formatting";
type Plan = {
title: string
monthly: number
egress: number
storage: number
checkout_cadence_1?: string
checkout_cadence_6?: string
checkout_cadence_12?: string
}
const gb = 1e9
const tb = gb*1000
const euro = 1e6
const plans: Plan[] = [
{
title: "Luna",
monthly: 2*euro,
egress: 2*tb,
storage: 500*gb,
// checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://www.creem.io/test/payment/prod_4WIbZB54NgOTVSGEe8kwpA",
checkout_cadence_12: "https://www.creem.io/test/payment/prod_4A8depiWgKgJ5h5tJD2Sv7",
}, {
title: "Io",
monthly: 4*euro,
egress: 4*tb,
storage: 1*tb,
// checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Callisto",
monthly: 8*euro,
egress: 8*tb,
storage: 2*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Mercury",
monthly: 16*euro,
egress: 16*tb,
storage: 4*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Titan",
monthly: 32*euro,
egress: 32*tb,
storage: 8*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Ganymede",
monthly: 64*euro,
egress: 64*tb,
storage: 16*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Mars",
monthly: 128*euro,
egress: 128*tb,
storage: 32*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Venus",
monthly: 256*euro,
egress: 256*tb,
storage: 64*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Terra",
monthly: 512*euro,
egress: 512*tb,
storage: 128*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Neptunus",
monthly: 1024*euro,
egress: 1024*tb,
storage: 256*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Uranus",
monthly: 2048*euro,
egress: 2048*tb,
storage: 512*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Saturnus",
monthly: 4096*euro,
egress: 4096*tb,
storage: 1024*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Jupiter",
monthly: 8192*euro,
egress: 8192*tb,
storage: 2048*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}, {
title: "Sol",
monthly: 16384*euro,
egress: 16384*tb,
storage: 4096*tb,
checkout_cadence_1: "https://example.com",
checkout_cadence_6: "https://example.com",
checkout_cadence_12: "https://example.com",
}
]
</script>
<div class="horizontal_scroll">
<div class="feat_table">
{#each plans as plan (plan.title)}
<div class="plan">
<div class="title">
<span class="bold">{plan.title}</span>
<br/>
<Euro amount={plan.monthly}/> / month
</div>
<div class="features">
<div>
<span class="bold">{formatDataVolume(plan.storage, 0)}</span>
storage space
</div>
<div>
<span class="bold">{formatDataVolume(plan.egress, 0)}</span>
egress traffic per month
</div>
</div>
<div class="order">
{#if plan.checkout_cadence_1 !== undefined}
<a href="{plan.checkout_cadence_1}" class="button">
<div class="bold">Monthly</div>
<div><Euro amount={plan.monthly}/> / month</div>
</a>
{:else}
<span>Monthly plan not available</span>
{/if}
{#if plan.checkout_cadence_6 !== undefined}
<a href="{plan.checkout_cadence_6}" class="button">
<div class="bold">Bi-yearly (save 10%)</div>
<div><Euro amount={plan.monthly*6*.9}/> / 6 months</div>
</a>
{:else}
<span>Bi-yearly plan not available</span>
{/if}
{#if plan.checkout_cadence_12 !== undefined}
<a href="{plan.checkout_cadence_12}" class="button">
<div class="bold">Yearly (save 20%)</div>
<div><Euro amount={plan.monthly*12*.8}/> / year</div>
</a>
{:else}
<span>Yearly plan not available</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
<style>
.horizontal_scroll {
overflow-x: scroll;
overflow-y: hidden;
width: 100%;
}
.feat_table {
display: flex;
flex-direction: row;
gap: 8px;
padding: 8px;
}
.plan {
flex: 0 0 18em;
display: flex;
width: 18em;
flex-direction: column;
border: 1px solid var(--highlight_color);
border-radius: 8px;
background-color: var(--body_color);
}
.plan > div {
flex: 1 1 0;
margin: 0.2em;
padding: 0.5em;
text-align: center;
word-wrap: break-word;
hyphens: auto;
}
.plan > .order {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.plan > .order > .button {
flex-direction: column;
}
.bold {
font-weight: bold;
}
</style>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
let { let {
icon_href = "", icon_href = "",
width = "750px", width = "800px",
children children
}: { }: {
icon_href?: string; icon_href?: string;
@@ -12,7 +12,7 @@ let {
<div class="block" style="width: {width}; max-width: 100%"> <div class="block" style="width: {width}; max-width: 100%">
<img src={icon_href} alt="File icon" class="icon"> <img src={icon_href} alt="File icon" class="icon">
<div class="description"> <div class="description bubble">
{@render children?.()} {@render children?.()}
</div> </div>
</div> </div>
@@ -47,5 +47,6 @@ let {
background-color: var(--shaded_background); background-color: var(--shaded_background);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
padding: 8px; padding: 8px;
margin: 0;
} }
</style> </style>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
let { let {
width = "750px", width = "800px",
center = false, center = false,
children children
}: { }: {
@@ -10,17 +10,25 @@ let {
} = $props(); } = $props();
</script> </script>
<div class="block" class:center style="width: {width};"> <div class="width_container">
{@render children?.()} <div class="block bubble" class:center style="width: {width};">
{@render children?.()}
</div>
</div> </div>
<style> <style>
.width_container {
display: flex;
justify-content: center;
width: 100%;
margin: auto;
}
.block { .block {
display: block; display: block;
text-align: initial; text-align: initial;
max-width: 100%; max-width: 100%;
overflow-wrap: anywhere; overflow-wrap: anywhere;
margin: auto; /* margin: auto; */
background-color: var(--shaded_background); background-color: var(--shaded_background);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);

View File

@@ -1,5 +1,5 @@
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { fs_check_response, fs_path_url, type FSNode } from "./FilesystemAPI.svelte" import { fs_check_response, fs_path_url, type FSNode } from "./FilesystemAPI"
import { loading_finish, loading_start } from "lib/Loading" import { loading_finish, loading_start } from "lib/Loading"
import { get_user } from "lib/NovaAPI" import { get_user } from "lib/NovaAPI"
@@ -27,12 +27,7 @@ export const bookmarks_get = async (): Promise<Bookmark[]> => {
try { try {
loading_start() loading_start()
const user = await get_user() const user = await get_user()
if ( if (user === null) {
user.username === undefined ||
user.username === "" ||
user.subscription.filesystem_access === undefined ||
user.subscription.filesystem_access === false
) {
return [] return []
} }

View File

@@ -103,7 +103,8 @@ export type FSPermissions = {
} }
export type FSContext = { export type FSContext = {
premium_transfer: boolean, premium_transfer: boolean
can_share: boolean
} }
// API parameters // API parameters

View File

@@ -1,6 +1,8 @@
// Response types // Response types
// ============== // ==============
import { user } from "./UserStore"
export type GenericResponse = { export type GenericResponse = {
value: string, value: string,
message: string, message: string,
@@ -35,14 +37,11 @@ export type Subscription = {
name: string, name: string,
type: string, type: string,
file_size_limit: number, file_size_limit: number,
file_expiry_days: number, storage_limit: number,
storage_space: number,
price_per_tb_storage: number, price_per_tb_storage: number,
price_per_tb_bandwidth: number, price_per_tb_bandwidth: number,
monthly_transfer_cap: number, monthly_transfer_cap: number,
file_viewer_branding: boolean, file_sharing: boolean,
filesystem_access: boolean,
filesystem_storage_limit: number,
} }
// Utility funcs // Utility funcs
@@ -107,34 +106,66 @@ export type UserSession = {
valid_domains: string[], valid_domains: string[],
} }
let cached_user: User = null // If cached_user is undefined it means that the value is not initialized yet,
// null means the user is not logged in
let cached_user: User = undefined
// The request which is currently in flight. Callers which arrive while the
// request is still running share this promise, so the user is only requested
// from the server once
let user_request: Promise<User> | null = null
export const get_user = async (): Promise<User> => { export const get_user = async (): Promise<User> => {
if ((window as any).user !== undefined) { if (cached_user !== undefined) {
return (window as any).user as User
} else if (cached_user !== null) {
return cached_user return cached_user
} }
if (user_request === null) {
user_request = request_user()
}
console.warn("user property is not defined on window") const u = await user_request
if (u === undefined) {
// The request failed. Clear it so the next caller tries again
user_request = null
}
cached_user = await check_response(await fetch(get_endpoint() + "/user")) as User return u
}
const request_user = async (): Promise<User> => {
try {
let resp = await fetch(get_endpoint() + "/user")
if (resp.status === 401) {
// User is not authenticated
cached_user = null
} else if (resp.status >= 400) {
throw await resp.text()
} else {
cached_user = (await resp.json()) as User
}
} catch (err) {
alert("failed to fetch user:\n" + err)
}
user.set(cached_user)
return cached_user return cached_user
} }
export const put_user = async (data: Object) => { export const put_user = async (data: Object) => {
check_response(await fetch( await check_response(await fetch(
get_endpoint() + "/user", get_endpoint() + "/user",
{ method: "PUT", body: dict_to_form(data) }, { method: "PUT", body: dict_to_form(data) },
)) ))
// Update the window.user variable // Update the user store
for (let key of Object.keys(data)) { for (let key of Object.keys(data)) {
((window as any).user as User)[key] = data[key] cached_user[key] = data[key]
} }
user.set(cached_user)
} }
export const logout_user = async (redirect_path: string) => { export const logout_user = async (redirect_path: string) => {
check_response(await fetch( await check_response(await fetch(
get_endpoint() + "/user/session", get_endpoint() + "/user/session",
{ method: "DELETE" }, { method: "DELETE" },
)) ))

View File

@@ -1,8 +1,8 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { get_user, type Subscription, type User } from "./NovaAPI"; import { get_user, type User } from "./NovaAPI";
export const user = writable( export const user = writable(
{ subscription: {} as Subscription } as User, null as User | null,
(set: (value: User) => void) => { (set: (value: User) => void) => {
get_user().then((u: User) => { get_user().then((u: User) => {
set(u) set(u)

View File

@@ -1,12 +1,16 @@
<script> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import CopyButton from "layout/CopyButton.svelte"; import CopyButton from "layout/CopyButton.svelte";
import Form from "util/Form.svelte"; import Form, { type FormConfig } from "util/Form.svelte";
import Button from "layout/Button.svelte"; import Button from "layout/Button.svelte";
import OtpSetup from "./OTPSetup.svelte"; import OtpSetup from "./OTPSetup.svelte";
import { put_user } from "lib/NovaAPI"; import { get_endpoint, put_user } from "lib/NovaAPI";
import { user } from "lib/UserStore";
let affiliate_link = window.location.protocol+"//"+window.location.host + "?ref=" + encodeURIComponent(window.user.username) let affiliate_link = $derived(
window.location.protocol+"//"+window.location.host +
"?ref=" + encodeURIComponent($user.username)
)
let affiliate_deny = $state(false) let affiliate_deny = $state(false)
onMount(() => { onMount(() => {
affiliate_deny = localStorage.getItem("affiliate_deny") === "1" affiliate_deny = localStorage.getItem("affiliate_deny") === "1"
@@ -16,14 +20,13 @@ const enable_affiliate_prompt = () => {
localStorage.removeItem("affiliate_deny") localStorage.removeItem("affiliate_deny")
} }
let account_settings = { let account_settings: FormConfig = $derived({
name: "account_settings",
fields: [ fields: [
{ {
name: "email", name: "email",
label: "E-mail address", label: "E-mail address",
type: "email", type: "email",
default_value: window.user.email, default_value: $user.email,
description: `We will send an e-mail to the new address to verify description: `We will send an e-mail to the new address to verify
that it's real. The address will be saved once the link in the that it's real. The address will be saved once the link in the
message is clicked. If the e-mail doesn't arrive right away message is clicked. If the e-mail doesn't arrive right away
@@ -48,7 +51,7 @@ let account_settings = {
name: "username", name: "username",
label: "Name", label: "Name",
type: "username", type: "username",
default_value: window.user.username, default_value: $user.username,
description: `Changing your username also changes the name used to description: `Changing your username also changes the name used to
log in. If you forget your username you can still log in using log in. If you forget your username you can still log in using
your e-mail address if you have one configured`, your e-mail address if you have one configured`,
@@ -57,17 +60,17 @@ let account_settings = {
name: "checkout_country", name: "checkout_country",
label: "Country code used at checkout", label: "Country code used at checkout",
type: "text", type: "text",
default_value: window.user.checkout_country, default_value: $user.checkout_country,
}, { }, {
name: "checkout_provider", name: "checkout_provider",
label: "Default payment provider used at checkout", label: "Default payment provider used at checkout",
type: "text", type: "text",
default_value: window.user.checkout_provider, default_value: $user.checkout_provider,
}, { }, {
name: "checkout_name", name: "checkout_name",
label: "Full name used at checkout", label: "Full name used at checkout",
type: "text", type: "text",
default_value: window.user.checkout_name, default_value: $user.checkout_name,
separator: true, separator: true,
}, },
], ],
@@ -98,21 +101,20 @@ let account_settings = {
checkout_name: fields.checkout_name, checkout_name: fields.checkout_name,
}) })
} catch (err) { } catch (err) {
return {error_json: err} return {success: false, error_json: err}
} }
return {success: true, message: "Success! Your changes have been saved"} return {success: true, message: "Success! Your changes have been saved"}
}, },
} })
const affiliate_settings = { let affiliate_settings: FormConfig = $derived({
name: "affiliate_settings",
fields: [ fields: [
{ {
name: "affiliate_user_name", name: "affiliate_user_name",
label: "Affiliate user name", label: "Affiliate user name",
type: "text", type: "text",
default_value: window.user.affiliate_user_name, default_value: $user.affiliate_user_name,
description: `The affiliate user name can be the name of a description: `The affiliate user name can be the name of a
Nova account you wish to support with your subscription. Nova account you wish to support with your subscription.
The account will receive a fee of €0.50 for every month that The account will receive a fee of €0.50 for every month that
@@ -125,16 +127,15 @@ const affiliate_settings = {
const form = new FormData() const form = new FormData()
form.append("affiliate_user_name", fields.affiliate_user_name) form.append("affiliate_user_name", fields.affiliate_user_name)
const resp = await fetch(window.api_endpoint+"/user", { method: "PUT", body: form }); const resp = await fetch(get_endpoint()+"/user", { method: "PUT", body: form });
if(resp.status >= 400) { if(resp.status >= 400) {
return {error_json: await resp.json()} return {success: false, error_json: await resp.json()}
} }
return {success: true, message: "Success! Your changes have been saved"} return {success: true, message: "Success! Your changes have been saved"}
}, },
} })
let delete_account = { let delete_account: FormConfig = {
name: "delete_account",
fields: [ fields: [
{ {
name: "description", name: "description",
@@ -169,13 +170,13 @@ let delete_account = {
submit_label: `<i class="icon">delete</i> Delete`, submit_label: `<i class="icon">delete</i> Delete`,
on_submit: async fields => { on_submit: async fields => {
const resp = await fetch( const resp = await fetch(
window.api_endpoint+"/user", get_endpoint()+"/user",
{ method: "DELETE" } { method: "DELETE" }
); );
if(resp.status >= 400) { if(resp.status >= 400) {
return {error_json: await resp.json()} return {success: false, error_json: await resp.json()}
} }
setTimeout(() => { window.location = "/" }, 6000) setTimeout(() => { window.location.assign("/") }, 6000)
return {success: true, message: "Success! Your account has been scheduled for deletion in 7 days"} return {success: true, message: "Success! Your account has been scheduled for deletion in 7 days"}
}, },
} }
@@ -201,7 +202,7 @@ let delete_account = {
<a href="{affiliate_link}">{affiliate_link}</a> <a href="{affiliate_link}">{affiliate_link}</a>
<CopyButton small_icon text={affiliate_link}/>. Share this link <CopyButton small_icon text={affiliate_link}/>. Share this link
with premium Nova users to gain commissions. You can use with premium Nova users to gain commissions. You can use
the "?ref={encodeURIComponent(window.user.username)}" referral the "?ref={encodeURIComponent($user.username)}" referral
code on download pages too. For a detailed description of the code on download pages too. For a detailed description of the
affiliate program please check out the <a affiliate program please check out the <a
href="/about#toc_12">Q&A page</a>. href="/about#toc_12">Q&A page</a>.

View File

@@ -6,31 +6,22 @@ import { formatDataVolume } from "util/Formatting";
import ProgressBar from "util/ProgressBar.svelte"; import ProgressBar from "util/ProgressBar.svelte";
import SuccessMessage from "util/SuccessMessage.svelte"; import SuccessMessage from "util/SuccessMessage.svelte";
import { loading_finish, loading_start } from "lib/Loading"; import { loading_finish, loading_start } from "lib/Loading";
import { put_user } from 'lib/NovaAPI';
import { user } from 'lib/UserStore';
let success_message = $state() let success_message = $state()
let hotlinking = $state(window.user.hotlinking_enabled) let hotlinking = $state($user === null ? false : $user.hotlinking_enabled)
let transfer_cap = $state(window.user.monthly_transfer_cap / 1e12) let transfer_cap = $state($user === null ? 0 : $user.monthly_transfer_cap / 1e12)
let skip_viewer = $state(window.user.skip_file_viewer) let skip_viewer = $state($user === null ? false : $user.skip_file_viewer)
const update = async () => { const update = async () => {
const form = new FormData()
form.append("hotlinking_enabled", hotlinking)
form.append("transfer_cap", transfer_cap*1e12)
form.append("skip_file_viewer", skip_viewer)
loading_start() loading_start()
try { try {
const resp = await fetch( await put_user({
window.api_endpoint+"/user", hotlinking_enabled: hotlinking,
{ method: "PUT", body: form }, transfer_cap: transfer_cap*1e12,
) skip_file_viewer: skip_viewer,
if(resp.status >= 400) { })
let json = await resp.json()
throw json.message
}
window.user.hotlinking_enabled = hotlinking
window.user.monthly_transfer_cap = transfer_cap*1e12
success_message.set(true, "Sharing settings updated") success_message.set(true, "Sharing settings updated")
} catch (err) { } catch (err) {

View File

@@ -1,48 +1,18 @@
<script> <script lang="ts">
import Checkout from "layout/checkout/Checkout.svelte"; import Checkout from "layout/checkout/Checkout.svelte";
import { loading_finish, loading_start } from "lib/Loading"; import { loading_finish, loading_start } from "lib/Loading";
import { get_endpoint } from "lib/NovaAPI";
import { user } from "lib/UserStore";
import { onMount } from "svelte"; import { onMount } from "svelte";
import Euro from "util/Euro.svelte"; import Euro from "util/Euro.svelte";
import { formatDate } from "util/Formatting"; import { formatDate } from "util/Formatting";
let credit_amount = 10
const checkout = async (network = "", amount = 0, country = "") => {
if (amount < 10) {
alert("Amount needs to be at least €10")
return
}
const form = new FormData()
form.set("amount", amount*1e6)
form.set("network", network)
form.set("country", country)
loading_start()
try {
const resp = await fetch(
window.api_endpoint+"/user/invoice",
{method: "POST", body: form},
)
if(resp.status >= 400) {
let json = await resp.json()
throw json.message
}
window.location = (await resp.json()).checkout_url
} catch (err) {
alert(err)
} finally {
loading_finish()
}
}
let invoices = $state([]) let invoices = $state([])
let unpaid_invoice = $state(0) let unpaid_invoice = $state(0)
const load_invoices = async () => { const load_invoices = async () => {
loading_start() loading_start()
try { try {
const resp = await fetch(window.api_endpoint+"/user/invoice") const resp = await fetch(get_endpoint()+"/user/invoice")
if(resp.status >= 400) { if(resp.status >= 400) {
throw new Error((await resp.json()).message) throw new Error((await resp.json()).message)
} }
@@ -81,7 +51,7 @@ onMount(() => {
Prepaid there are no limits. You pay for what you use, at a rate of €4 Prepaid there are no limits. You pay for what you use, at a rate of €4
per TB per month for storage and €1 per TB for data transfer. Your per TB per month for storage and €1 per TB for data transfer. Your
current account balance is <Euro current account balance is <Euro
amount={window.user.balance_micro_eur}/> amount={$user === null ? 0 : $user.balance_micro_eur}/>
</p> </p>
{#if unpaid_invoice > 1} {#if unpaid_invoice > 1}

View File

@@ -1,53 +1,65 @@
<script> <script lang="ts">
import { preventDefault } from 'svelte/legacy';
import { onMount } from "svelte"; import { onMount } from "svelte";
import Persistence from "icons/Persistence.svelte";
import SuccessMessage from "util/SuccessMessage.svelte"; import SuccessMessage from "util/SuccessMessage.svelte";
import { loading_finish, loading_start } from "lib/Loading"; import { get_endpoint, get_user, type User } from "lib/NovaAPI";
import { loading_run } from "lib/Loading";
let success_message = $state() let success_message: SuccessMessage
// Embedding settings // Embedding settings
let embed_domains = $state("") let embed_domains: string[] = []
const save_embed = async () => { const save_embed = async () => {
const form = new FormData() await loading_run(async () => {
form.append("embed_domains", embed_domains) const domain_list = embed_domains.join(" ")
const form = new FormData()
form.append("embed_domains", domain_list)
loading_start() try {
try { const resp = await fetch(
const resp = await fetch( get_endpoint()+"/user",
window.api_endpoint+"/user", { method: "PUT", body: form }
{ method: "PUT", body: form } );
); if(resp.status >= 400) {
if(resp.status >= 400) { let json = await resp.json()
let json = await resp.json() console.debug(json)
console.debug(json) throw json.message
throw json.message }
success_message.set(true, "Changes saved")
} catch(err) {
success_message.set(false, err)
} }
})
success_message.set(true, "Changes saved")
} catch(err) {
success_message.set(false, err)
} finally {
loading_finish()
}
} }
onMount(() => { let new_domain: string = ""
embed_domains = window.user.file_embed_domains const add = () => {
console.debug("Adding domain", new_domain)
embed_domains.push(new_domain)
embed_domains = embed_domains
new_domain = ""
save_embed()
}
const remove = (index: number) => {
embed_domains.splice(index, 1)
embed_domains = embed_domains
save_embed()
}
let user: User
onMount(async () => {
user = await get_user()
if (user.file_embed_domains !== "") {
embed_domains = user.file_embed_domains.split(" ")
}
}) })
</script> </script>
<section> <section>
<h2><Persistence/>Embedding controls</h2> <h2>Embedding controls</h2>
<SuccessMessage bind:this={success_message}></SuccessMessage> <SuccessMessage bind:this={success_message}></SuccessMessage>
{#if !window.user.subscription.file_viewer_branding} {#if user !== undefined && !user.hotlinking_enabled}
<div class="highlight_yellow">
Sharing settings are not available for your account. Subscribe to
the Persistence plan or higher to enable these features.
</div>
{:else if !window.user.hotlinking_enabled}
<div class="highlight_yellow"> <div class="highlight_yellow">
To use embedding restrictions hotlinking needs to be enabled. To use embedding restrictions hotlinking needs to be enabled.
Enable hotlinking on the Enable hotlinking on the
@@ -57,25 +69,45 @@ onMount(() => {
<p> <p>
Here you can control which websites are allowed to embed your files in Here you can control which websites are allowed to embed your files in
their web pages. If a website that is not on this list tries to embed their web pages. If a website that is not on this list tries to embed
one of your files the request will be blocked. one of your files the request will be blocked. This applies to both
hotlink and iframe embeds. If the list is empty, all domains are allowed
to embed your files.
</p> </p>
<p> <div class="highlight_border">
The list should be formatted as a list of domain names separated by a <form on:submit|preventDefault={() => add()}>
space. Like this: 'nova.storage google.com twitter.com' <div class="form_row">
</p> <span>Add domain name</span>
Domain names:<br/> <input class="grow" bind:value={new_domain} type="text"/>
<form class="form_row" onsubmit={preventDefault(save_embed)}> <button class="shrink" type="submit"><i class="icon">add</i> Add</button>
<input class="grow" bind:value={embed_domains} type="text"/> </div>
<button class="shrink" action="submit"><i class="icon">save</i> Save</button> </form>
</form> <hr/>
{#if embed_domains.length === 0}
<span>
No domains specified. All websites are allowed to embed your files
</span>
{/if}
{#each embed_domains as domain, index}
<div class="form_row">
<button class="shrink" type="button" on:click={() => remove(index)}>
<i class="icon">delete</i>
</button>
<div>{domain}</div>
</div>
{/each}
</div>
</section> </section>
<style> <style>
.highlight_border {
text-align: initial;
}
.form_row { .form_row {
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
align-items: center; align-items: center;
gap: 0.5em;
} }
.grow { .grow {
flex: 1 1 auto; flex: 1 1 auto;

View File

@@ -34,7 +34,7 @@ let frac = $derived(used / total)
files have a daily download limit and hotlinking is disabled. files have a daily download limit and hotlinking is disabled.
</p> </p>
{#if $user.monthly_transfer_cap > 0} {#if $user !== null && $user.monthly_transfer_cap > 0}
<p> <p>
You have a billshock limit configured. <a You have a billshock limit configured. <a
href="/user/sharing/bandwidth">increase or disable the limit</a> to href="/user/sharing/bandwidth">increase or disable the limit</a> to
@@ -56,7 +56,7 @@ let frac = $derived(used / total)
and hotlinking is disabled. and hotlinking is disabled.
</p> </p>
{#if $user.monthly_transfer_cap > 0} {#if $user !== null && $user.monthly_transfer_cap > 0}
<p> <p>
You have a billshock limit configured. <a You have a billshock limit configured. <a
href="/user/sharing/bandwidth">increase or disable the limit</a> to href="/user/sharing/bandwidth">increase or disable the limit</a> to

View File

@@ -8,7 +8,7 @@ let { total = 0, used = 0, disable_warnings = false }: {
disable_warnings?: boolean; disable_warnings?: boolean;
} = $props(); } = $props();
let frac = $derived(used / total) let frac = $derived(total <= 0 ? 0 : used / total)
</script> </script>
<ProgressBar total={total} used={used}></ProgressBar> <ProgressBar total={total} used={used}></ProgressBar>

View File

@@ -6,8 +6,8 @@ import { loading_finish, loading_start } from "lib/Loading";
import { user } from "lib/UserStore"; import { user } from "lib/UserStore";
import { put_user } from "lib/NovaAPI"; import { put_user } from "lib/NovaAPI";
let subscription = $state($user.subscription.id) let subscription: string = $state($user === null ? "" : $user.subscription.id)
let subscription_type = $state($user.subscription.type) let subscription_type: string = $state($user === null ? "" : $user.subscription.type)
let success_message: SuccessMessage = $state() let success_message: SuccessMessage = $state()
const update = async (plan: string) => { const update = async (plan: string) => {

View File

@@ -1,35 +1,36 @@
<script> <script lang="ts">
import Button from "layout/Button.svelte";
import { logout_user } from "lib/NovaAPI"; import { logout_user } from "lib/NovaAPI";
import { user } from "lib/UserStore";
</script> </script>
<ul>
<li>Username: {window.user.username}</li> {#if $user !== null}
{#if window.user.email === ""} <ul>
<li class="highlight_blue" style="text-align: initial;"> <li>Username: {$user.username}</li>
No e-mail address configured. You will not be able to recover your {#if $user.email === ""}
account if you lose your password. Set an e-mail address on the <a <li class="highlight_blue" style="text-align: initial;">
href="/user/settings">Settings</a> page. No e-mail address configured. You will not be able to recover your
</li> account if you lose your password. Set an e-mail address on the <a
{:else} href="/user/settings">Settings</a> page.
</li>
{:else}
<li>
E-mail address: {$user.email}
</li>
{/if}
<li> <li>
E-mail address: {window.user.email} <i class="icon">settings</i>
<a href="/user/settings">Account settings</a>
</li> </li>
{/if} </ul>
<li> {/if}
<i class="icon">settings</i>
<a href="/user/settings">Account settings</a>
</li>
</ul>
<h3>Quick navigation</h3> <h3>Quick navigation</h3>
<div class="button_row"> <div class="button_row">
{#if window.user.subscription.filesystem_access} <a href="/d/me" class="button">
<a href="/d/me" class="button"> <i class="icon">folder</i>
<i class="icon">folder</i> Filesystem
Filesystem </a>
</a>
{/if}
<button onclick={()=> logout_user("/")}> <button onclick={()=> logout_user("/")}>
<i class="icon">logout</i> <i class="icon">logout</i>
Log out Log out

View File

@@ -1,59 +1,69 @@
<script> <script lang="ts">
import { user } from "lib/UserStore";
import Euro from "util/Euro.svelte"; import Euro from "util/Euro.svelte";
import { formatDataVolume } from "util/Formatting"; import { formatDataVolume } from "util/Formatting";
</script> </script>
<ul> {#if $user !== null}
<li> <ul>
Supporter level: {window.user.subscription.name}<br/>
<i class="icon">shopping_cart</i>
<a href="/user/subscription">Manage subscriptions</a><br/>
<i class="icon">add_link</i>
<a href="/api/patreon_auth/start">Link Patreon subscription</a>
</li>
{#if window.user.balance_micro_eur !== 0}
<li> <li>
Current account balance: <Euro amount={window.user.balance_micro_eur}></Euro><br/> Supporter level: {$user.subscription.name}<br/>
<i class="icon">shopping_cart</i>
<a href="/user/subscription">Manage subscriptions</a><br/>
<i class="icon">add_link</i>
<a href="/api/patreon_auth/start">Link Patreon subscription</a>
</li>
<i class="icon">account_balance_wallet</i> {#if $user.balance_micro_eur !== 0}
<a href="/user/prepaid/deposit">Deposit credit</a><br/> <li>
Current account balance: <Euro amount={$user.balance_micro_eur}></Euro><br/>
<i class="icon">receipt</i> <i class="icon">account_balance_wallet</i>
<a href="/user/prepaid/transactions">Transaction log</a> <a href="/user/prepaid/deposit">Deposit credit</a><br/>
{#if window.user.balance_micro_eur > 0 && window.user.subscription.id === ""} <i class="icon">receipt</i>
<br/> <a href="/user/prepaid/transactions">Transaction log</a>
You have account credit but no active subscription. Activate
a subscription on the <a href="/user/subscription">subscriptions page</a> {#if $user.balance_micro_eur > 0 && $user.subscription.id === ""}
<br/>
You have account credit but no active subscription. Activate
a subscription on the <a href="/user/subscription">subscriptions page</a>
{/if}
</li>
{/if}
<li>
Max file size: {formatDataVolume($user.subscription.file_size_limit, 3)}
</li>
<li>
{#if $user.subscription.storage_limit > 0}
Storage limit: {formatDataVolume($user.subscription.storage_limit, 3)}
{:else}
No storage limit
{/if} {/if}
</li> </li>
{/if}
<li>
Max file size: {formatDataVolume(window.user.subscription.file_size_limit, 3)}
</li>
<li>
{#if window.user.subscription.storage_space > 0}
Storage limit: {formatDataVolume(window.user.subscription.storage_space, 3)}
{:else}
No storage limit
{/if}
</li>
<li>
{#if window.user.subscription.monthly_transfer_cap > 0}
Data transfer limit: {formatDataVolume(window.user.subscription.monthly_transfer_cap, 3)}
{:else}
No data transfer limit
{/if}
</li>
{#if window.user.subscription.id !== ""}
<li> <li>
Support: For questions related to your account you can send a {#if $user.subscription.monthly_transfer_cap > 0}
message to support@nova.storage Data transfer limit: {formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
{:else}
No data transfer limit
{/if}
</li> </li>
{/if} <li>
</ul> {#if $user.subscription.file_sharing === true}
File sharing enabled
{:else}
File sharing not allowed
{/if}
</li>
{#if $user.subscription.id !== ""}
<li>
Support: For questions related to your account you can send a
message to support@nova.storage
</li>
{/if}
</ul>
{/if}
<style> <style>
ul { ul {

View File

@@ -42,7 +42,7 @@ onMount(async () => {
transfer_cap = -1 transfer_cap = -1
} }
storage_limit = user.subscription.storage_space storage_limit = user.subscription.storage_limit
load_direct_bw() load_direct_bw()
}) })

View File

@@ -127,7 +127,7 @@ onMount(() => {
{#each cards as card, i (card.id)} {#each cards as card, i (card.id)}
<div <div
animate:flip={{duration: 250}} animate:flip={{duration: 250}}
class="card" class="card bubble"
class:size_1={card.size === 1} class:size_1={card.size === 1}
class:size_2={card.size === 2} class:size_2={card.size === 2}
class:size_3={card.size === 3} class:size_3={card.size === 3}
@@ -175,17 +175,15 @@ onMount(() => {
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
padding: 8px 0; padding: 8px 0;
margin: 0 8px; margin: 0 4px;
} }
.card { .card {
flex: 1 0 auto; flex: 1 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 100%; max-width: 100%;
background: var(--body_background);
border-radius: 8px;
border: 1px solid var(--separator);
text-align: initial; text-align: initial;
margin: 0;
} }
.card_component { .card_component {
flex: 1 1 auto; flex: 1 1 auto;

View File

@@ -14,7 +14,6 @@ export type FormField = {
separator?: boolean, separator?: boolean,
radio_values?: string[], // Options to choose from when type is "radio" radio_values?: string[], // Options to choose from when type is "radio"
pattern?: string, // Used for pattern matching on input fields pattern?: string, // Used for pattern matching on input fields
binding?: any
}; };
export type SubmitResult = { export type SubmitResult = {
success: boolean, success: boolean,
@@ -24,54 +23,62 @@ export type SubmitResult = {
}; };
</script> </script>
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { loading_run } from "lib/Loading";
import Spinner from "./Spinner.svelte";
import type { GenericResponse } from "lib/NovaAPI"; import type { GenericResponse } from "lib/NovaAPI";
let { config }: { let { config = $bindable() }: {
config: FormConfig; config: FormConfig;
} = $props(); } = $props();
onMount(() => { // The current value of each input, keyed by field name. The values live here
// instead of on the config object because the config is usually a plain object
// which svelte can't track changes on
const initial_values = () => {
let values: {[key: string]: any} = {}
config.fields.forEach(field => { config.fields.forEach(field => {
if(field.default_value === undefined) { if (field.name !== undefined) {
field.default_value = "" values[field.name] = field.default_value ?? ""
} }
}) })
return values
}
let values: {[key: string]: any} = $state(initial_values())
// When the parent swaps out the form (or rebuilds its config) the inputs are
// reset to the new default values
let rendered_fields = config.fields
$effect(() => {
if (config.fields !== rendered_fields) {
rendered_fields = config.fields
values = initial_values()
}
}) })
let loading = $state(false)
let submitted = $state(false) let submitted = $state(false)
let submit_result: SubmitResult = $state() let submit_result: SubmitResult = $state()
const submit = async (event: SubmitEvent) => { const submit = async (event: SubmitEvent) => {
loading = true return await loading_run(async () => {
event.preventDefault() event.preventDefault()
let field_values = {} let field_values = {}
config.fields.forEach(field => { config.fields.forEach(field => {
if (field.type === "radio") { if (field.name === undefined) {
if (field.binding === undefined) { return
field_values[field.name] = ""
} else {
field_values[field.name] = field.binding
} }
} else if (field.type === "description") { const value = values[field.name]
field_values[field.name] = "" field_values[field.name] = value === undefined || value === null ? "" : String(value)
} else { })
field_values[field.name] = field.binding.value
submit_result = await config.on_submit(field_values)
if (submit_result && submit_result.error_json) {
submit_result = handle_errors(submit_result.error_json)
} }
submitted = true
return false
}) })
submit_result = await config.on_submit(field_values)
if (submit_result && submit_result.error_json) {
submit_result = handle_errors(submit_result.error_json)
}
submitted = true
loading = false
return false
} }
const field_label = (field: string) => { const field_label = (field: string) => {
@@ -144,94 +151,85 @@ const handle_errors = (response: GenericResponse) => {
{field.label} {field.label}
</label> </label>
{#if field.type === "text"} {#if field.type === "text"}
<input bind:this={field.binding} <input bind:value={values[field.name]}
id="input_{field.name}" id="input_{field.name}"
name="{field.name}" name="{field.name}"
value="{field.default_value}"
pattern={field.pattern} pattern={field.pattern}
type="text" type="text"
class="form_input" class="form_input"
/> />
{:else if field.type === "text_area"} {:else if field.type === "text_area"}
<textarea bind:this={field.binding} <textarea bind:value={values[field.name]}
id="input_{field.name}" id="input_{field.name}"
name="{field.name}" name="{field.name}"
class="form_input" class="form_input"
style="width: 100%; height: 10em; resize: vertical;" style="width: 100%; height: 10em; resize: vertical;"
>{field.default_value}</textarea> ></textarea>
{:else if field.type === "number"} {:else if field.type === "number"}
<input bind:this={field.binding} <input bind:value={values[field.name]}
id="input_{field.name}" id="input_{field.name}"
name="{field.name}" name="{field.name}"
value="{field.default_value}"
pattern={field.pattern} pattern={field.pattern}
type="number" type="number"
class="form_input" class="form_input"
/> />
{:else if field.type === "decimal"} {:else if field.type === "decimal"}
<input bind:this={field.binding} <input bind:value={values[field.name]}
id="input_{field.name}" id="input_{field.name}"
name="{field.name}" name="{field.name}"
value="{field.default_value}"
pattern={field.pattern} pattern={field.pattern}
type="number" type="number"
step="0.1" step="0.1"
class="form_input" class="form_input"
/> />
{:else if field.type === "datetime-local"} {:else if field.type === "datetime-local"}
<input bind:this={field.binding} <input bind:value={values[field.name]}
id="input_{field.name}" id="input_{field.name}"
name="{field.name}" name="{field.name}"
value="{field.default_value}"
pattern={field.pattern} pattern={field.pattern}
type="datetime-local" type="datetime-local"
class="form_input" class="form_input"
/> />
{:else if field.type === "username"} {:else if field.type === "username"}
<input bind:this={field.binding} <input bind:value={values[field.name]}
id="input_{field.name}" id="input_{field.name}"
name="{field.name}" name="{field.name}"
value="{field.default_value}"
pattern={field.pattern} pattern={field.pattern}
type="text" type="text"
autocomplete="username" autocomplete="username"
class="form_input" class="form_input"
/> />
{:else if field.type === "email"} {:else if field.type === "email"}
<input bind:this={field.binding} <input bind:value={values[field.name]}
id="input_{field.name}" id="input_{field.name}"
name="{field.name}" name="{field.name}"
value="{field.default_value}"
pattern={field.pattern} pattern={field.pattern}
type="email" type="email"
autocomplete="email" autocomplete="email"
class="form_input" class="form_input"
/> />
{:else if field.type === "current_password"} {:else if field.type === "current_password"}
<input bind:this={field.binding} <input bind:value={values[field.name]}
id="input_{field.name}" id="input_{field.name}"
name="{field.name}" name="{field.name}"
value="{field.default_value}"
pattern={field.pattern} pattern={field.pattern}
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
class="form_input" class="form_input"
/> />
{:else if field.type === "new_password"} {:else if field.type === "new_password"}
<input bind:this={field.binding} <input bind:value={values[field.name]}
id="input_{field.name}" id="input_{field.name}"
name="{field.name}" name="{field.name}"
value="{field.default_value}"
pattern={field.pattern} pattern={field.pattern}
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
class="form_input" class="form_input"
/> />
{:else if field.type === "totp"} {:else if field.type === "totp"}
<input bind:this={field.binding} <input bind:value={values[field.name]}
id="input_{field.name}" id="input_{field.name}"
name="{field.name}" name="{field.name}"
value="{field.default_value?field.default_value:""}"
type="text" type="text"
autocomplete="one-time-code" autocomplete="one-time-code"
pattern={"[0-9]{6}"} pattern={"[0-9]{6}"}
@@ -240,12 +238,11 @@ const handle_errors = (response: GenericResponse) => {
{:else if field.type === "radio"} {:else if field.type === "radio"}
<div> <div>
{#each field.radio_values as val} {#each field.radio_values as val}
<input bind:group={field.binding} <input bind:group={values[field.name]}
id="input_{field.name}_choice_{val}" id="input_{field.name}_choice_{val}"
name="{field.name}" name="{field.name}"
value={val} value={val}
type="radio" type="radio"/>
checked={val === field.default_value}/>
<label for="input_{field.name}_choice_{val}">{val}</label><br/> <label for="input_{field.name}_choice_{val}">{val}</label><br/>
{/each} {/each}
</div> </div>
@@ -268,20 +265,4 @@ const handle_errors = (response: GenericResponse) => {
<button type="submit" class="button_highlight">{@html config.submit_label}</button> <button type="submit" class="button_highlight">{@html config.submit_label}</button>
{/if} {/if}
</div> </div>
{#if loading}
<div class="spinner_container">
<Spinner></Spinner>
</div>
{/if}
</form> </form>
<style>
.spinner_container {
position: absolute;
top: 10px;
right: 10px;
height: 100px;
width: 100px;
}
</style>

View File

@@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import { run } from 'svelte/legacy';
let { let {
total = $bindable(0), total = $bindable(0),
used = 0, used = 0,
@@ -20,7 +18,7 @@ let {
} = $props(); } = $props();
let percent = $state(0) let percent = $state(0)
run(() => { $effect(() => {
// Avoid division by 0 // Avoid division by 0
if (total === 0) { if (total === 0) {
total = 1 total = 1

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { bookmarks_save, bookmarks_store } from "lib/Bookmarks"; import { bookmarks_save, bookmarks_store } from "lib/Bookmarks";
import { fs_encode_path, fs_thumbnail_url } from "lib/FilesystemAPI.svelte"; import { fs_encode_path, fs_thumbnail_url } from "lib/FilesystemAPI";
import { highlight_current_page } from "lib/HighlightCurrentPage"; import { highlight_current_page } from "lib/HighlightCurrentPage";
import MenuEntry from "./MenuEntry.svelte"; import MenuEntry from "./MenuEntry.svelte";
import { flip } from "svelte/animate"; import { flip } from "svelte/animate";

View File

@@ -9,7 +9,7 @@ import { formatDataVolume } from "util/Formatting";
import Router from "wrap/Router.svelte"; import Router from "wrap/Router.svelte";
import Bookmarks from "./Bookmarks.svelte"; import Bookmarks from "./Bookmarks.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { fs_get_node } from "lib/FilesystemAPI.svelte"; import { fs_get_node } from "lib/FilesystemAPI";
import { css_from_path } from "filesystem/edit_window/Branding"; import { css_from_path } from "filesystem/edit_window/Branding";
import { loading_run, loading_store } from "lib/Loading"; import { loading_run, loading_store } from "lib/Loading";
import Spinner from "util/Spinner.svelte"; import Spinner from "util/Spinner.svelte";
@@ -32,12 +32,12 @@ const min_screen_size_fullscreen_menu = 500
// If the screen is smaller than this the menu will be closed by default // If the screen is smaller than this the menu will be closed by default
const min_screen_size_menu_open = 800 const min_screen_size_menu_open = 800
onMount(() => { onMount(async () => {
if (document.documentElement.clientWidth < min_screen_size_menu_open) { if (document.documentElement.clientWidth < min_screen_size_menu_open) {
menu_close() menu_close()
} }
loading_run(async () => { await loading_run(async () => {
const user = await get_user() const user = await get_user()
if (user.username === undefined || user.username === "") { if (user.username === undefined || user.username === "") {
return return
@@ -79,10 +79,14 @@ const touchmove = (e: TouchEvent) => {
// The cursor must have moved at least swipe_dead_zone pixels and twice as // The cursor must have moved at least swipe_dead_zone pixels and twice as
// much on the x axis than the y axis for it to count as a swipe // much on the x axis than the y axis for it to count as a swipe
if (abs_x > swipe_dead_zone && abs_x / 2 > abs_y) { if (abs_x > swipe_dead_zone) {
set_offset(initial_offset+(x*2)) if (abs_x > abs_y) {
} else { e.preventDefault()
touchend(e) e.stopPropagation()
set_offset(initial_offset+(x*2))
} else {
touchend(e)
}
} }
} }
@@ -151,7 +155,7 @@ const set_offset = (off: number) => {
<div class="wrap"> <div class="wrap">
<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 bubble" bind:this={nav}>
{#if document.documentElement.clientWidth < min_screen_size_menu_open} {#if document.documentElement.clientWidth < min_screen_size_menu_open}
<button class="button" onclick={toggle_menu}> <button class="button" onclick={toggle_menu}>
<i class="icon">menu</i> <i class="icon">menu</i>
@@ -159,26 +163,12 @@ const set_offset = (off: number) => {
</button> </button>
{/if} {/if}
<MenuEntry id="home" collapsed={false} no_border> <a class="button" style="flex: 1 1 auto;" href="/" use:highlight_current_page>
{#snippet title()} <span style="flex: 1 1 auto;">Nova.storage</span>
<a class="button" style="flex: 1 1 auto;" href="/" use:highlight_current_page> <img src="/res/img/nova_64.png" style="width: 1.5em; height: 1.5em; flex: 0 0 auto;" alt="Nova.storage logo">
<span style="flex: 1 1 auto;">Nova.storage</span> </a>
<img src="/res/img/nova_64.png" style="width: 1.5em; height: 1.5em; flex: 0 0 auto;" alt="Nova.storage logo">
</a>
{/snippet}
{#snippet body()}
<a class="button" href="/speedtest" use:highlight_current_page>
<i class="icon">speed</i>
<span>Speedtest</span>
</a>
<a class="button" href="/appearance" use:highlight_current_page>
<i class="icon">palette</i>
<span>Themes</span>
</a>
{/snippet}
</MenuEntry>
{#if $user.username !== undefined && $user.username !== ""} {#if $user !== null}
<MenuEntry id="subscription_info" collapsed={false}> <MenuEntry id="subscription_info" collapsed={false}>
{#snippet title()} {#snippet title()}
<div class="username">{$user.username}</div> <div class="username">{$user.username}</div>
@@ -202,6 +192,10 @@ const set_offset = (off: number) => {
{/snippet} {/snippet}
</MenuEntry> </MenuEntry>
<a class="button" href="/user" use:highlight_current_page>
<i class="icon">person</i>
<span>Account</span>
</a>
<a class="button" href="/d/me" use:highlight_current_page> <a class="button" href="/d/me" use:highlight_current_page>
<i class="icon">folder</i> <i class="icon">folder</i>
<span>Filesystem</span> <span>Filesystem</span>
@@ -210,10 +204,6 @@ const set_offset = (off: number) => {
<i class="icon">delete</i> <i class="icon">delete</i>
<span>Trash</span> <span>Trash</span>
</a> </a>
<a class="button" href="/user" use:highlight_current_page>
<i class="icon">person</i>
<span>Account</span>
</a>
{#if $user.is_admin} {#if $user.is_admin}
<a class="button" href="/admin" use:highlight_current_page> <a class="button" href="/admin" use:highlight_current_page>
<i class="icon">admin_panel_settings</i> <i class="icon">admin_panel_settings</i>
@@ -231,6 +221,24 @@ const set_offset = (off: number) => {
</a> </a>
{/if} {/if}
<MenuEntry id="other_pages" collapsed={false}>
{#snippet title()}
<span style="flex: 1 1 auto;">
<span style="flex: 1 1 auto;">Other pages</span>
</span>
{/snippet}
{#snippet body()}
<a class="button" href="/speedtest" use:highlight_current_page>
<i class="icon">speed</i>
<span>Speedtest</span>
</a>
<a class="button" href="/appearance" use:highlight_current_page>
<i class="icon">palette</i>
<span>Themes</span>
</a>
{/snippet}
</MenuEntry>
<Bookmarks/> <Bookmarks/>
<Tree/> <Tree/>
</nav> </nav>
@@ -238,7 +246,7 @@ const set_offset = (off: number) => {
</div> </div>
<div class="page"> <div class="page">
<div class="header"> <div class="header bubble">
<button class="menu_button" onclick={toggle_menu}> <button class="menu_button" onclick={toggle_menu}>
<i class="icon">menu</i> <i class="icon">menu</i>
</button> </button>
@@ -253,7 +261,6 @@ const set_offset = (off: number) => {
<Router/> <Router/>
</div> </div>
{#if $loading_store !== 0} {#if $loading_store !== 0}
<div class="spinner"> <div class="spinner">
<Spinner/> <Spinner/>
@@ -279,8 +286,6 @@ const set_offset = (off: number) => {
} }
.nav_container { .nav_container {
flex: 0 0 auto; flex: 0 0 auto;
border-right: 1px solid var(--separator);
background: var(--body_background);
backdrop-filter: blur(3px); backdrop-filter: blur(3px);
z-index: 9; z-index: 9;
} }
@@ -291,11 +296,15 @@ const set_offset = (off: number) => {
overflow-y: auto; overflow-y: auto;
max-height: 100vh; max-height: 100vh;
height: 100vh; height: 100vh;
padding: 4px;
scrollbar-gutter: stable;
} }
.nav { .nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 15em; width: 15em;
margin: 0;
padding: 4px;
} }
.button { .button {
background: none; background: none;
@@ -326,11 +335,9 @@ const set_offset = (off: number) => {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: left; justify-content: left;
background: var(--body_background);
border-bottom: 1px solid var(--separator);
backdrop-filter: blur(3px); backdrop-filter: blur(3px);
position: sticky; position: sticky;
top: 0; top: 4px;
z-index: 8; /* below navigation, on top of most other things */ z-index: 8; /* below navigation, on top of most other things */
} }
.menu_button { .menu_button {
@@ -344,6 +351,7 @@ const set_offset = (off: number) => {
flex-wrap: wrap; flex-wrap: wrap;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
overflow-x: hidden;
} }
.spinner { .spinner {
@@ -352,5 +360,6 @@ const set_offset = (off: number) => {
right: 10px; right: 10px;
height: 120px; height: 120px;
width: 120px; width: 120px;
z-index: 11; /* One above the menu */
} }
</style> </style>

View File

@@ -64,6 +64,8 @@ const toggle = (e: MouseEvent) => {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
border-top: 1px solid var(--separator); border-top: 1px solid var(--separator);
margin-top: 4px;
padding-top: 4px;
} }
.no_border { .no_border {
border-top: none; border-top: none;

View File

@@ -56,21 +56,26 @@ let pages: Tab[] = [
}, },
] ]
let user: User = null
onMount(async () => { onMount(async () => {
user = await get_user() await load_page(window.location.pathname, false)
load_page(window.location.pathname, false)
}) })
let current_page: Tab = $state(null) let current_page: Tab = $state(null)
const load_page = (pathname: string, history: boolean): boolean => { const load_page = async (pathname: string, history: boolean): Promise<boolean> => {
console.debug( console.debug(
"Page router: Navigating to page", pathname, "Page router: Navigating to page", pathname,
"current path", window.location.pathname, "current path", window.location.pathname,
"log history:", history, "log history:", history,
) )
let user: User
try {
user = await get_user()
} catch (err) {
alert("Failed to get user details: " + err)
}
const path_decoded = decodeURIComponent(pathname) const path_decoded = decodeURIComponent(pathname)
let page_by_path: Tab = null let page_by_path: Tab = null
let page_by_prefix: Tab = null let page_by_prefix: Tab = null
@@ -98,7 +103,7 @@ const load_page = (pathname: string, history: boolean): boolean => {
// If this page requires login, and the user is not logged in, then we // If this page requires login, and the user is not logged in, then we
// redirect the user to the login page // redirect the user to the login page
if (current_page.login === true && (user.username === "" || user.username === undefined)) { if (current_page.login === true && (user === null || user === undefined)) {
console.debug("User is not logged in, redirecting to login page", user) console.debug("User is not logged in, redirecting to login page", user)
return load_page("/login", true) return load_page("/login", true)
} }

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { global_navigator } from "filesystem/FSNavigator"; import { global_navigator } from "filesystem/FSNavigator";
import { fs_encode_path, fs_node_icon, FSNode } from "lib/FilesystemAPI.svelte"; import { fs_encode_path, fs_node_icon, FSNode } from "lib/FilesystemAPI";
import { highlight_current_page } from "lib/HighlightCurrentPage"; import { highlight_current_page } from "lib/HighlightCurrentPage";
import { onMount } from "svelte"; import { onMount } from "svelte";
import MenuEntry from "./MenuEntry.svelte"; import MenuEntry from "./MenuEntry.svelte";

View File

@@ -14,7 +14,7 @@ import (
func (wc *WebController) serveDirectory(w http.ResponseWriter, r *http.Request, p httprouter.Params) { func (wc *WebController) serveDirectory(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
var err error var err error
var td = wc.newTemplateData(w, r) var td = wc.newTemplateData(r)
var path = strings.TrimPrefix(p.ByName("path"), "/") var path = strings.TrimPrefix(p.ByName("path"), "/")
// Prevent search engines from indexing this page for privacy reasons // Prevent search engines from indexing this page for privacy reasons
@@ -25,7 +25,9 @@ func (wc *WebController) serveDirectory(w http.ResponseWriter, r *http.Request,
return return
} }
node, err := td.PixelAPI.GetFilesystemPath(path) pdapi := wc.api.RealIP(util.RemoteAddress(r)).RealAgent(r.UserAgent())
node, err := pdapi.GetFilesystemPath(path)
if err != nil { if err != nil {
if apiErr, ok := errors.AsType[pixelapi.Error](err); ok { if apiErr, ok := errors.AsType[pixelapi.Error](err); ok {
switch apiErr.StatusCode { switch apiErr.StatusCode {
@@ -59,15 +61,17 @@ func (wc *WebController) serveDirectory(w http.ResponseWriter, r *http.Request,
func (wc *WebController) serveForbidden(w http.ResponseWriter, r *http.Request) { func (wc *WebController) serveForbidden(w http.ResponseWriter, r *http.Request) {
log.Debug("Forbidden: %s", r.URL) log.Debug("Forbidden: %s", r.URL)
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
wc.templates.Run(w, r, "403", wc.newTemplateData(w, r)) if err := wc.templates.Run(w, r, "wrap", wc.newTemplateData(r)); err != nil {
log.Error("Failed to run template 403: %s", err)
}
} }
func (wc *WebController) serveNotFound(w http.ResponseWriter, r *http.Request) { func (wc *WebController) serveNotFound(w http.ResponseWriter, r *http.Request) {
log.Debug("Not Found: %s", r.URL) log.Debug("Not Found: %s", r.URL)
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
wc.templates.Run(w, r, "404", wc.newTemplateData(w, r)) wc.templates.Run(w, r, "404", wc.newTemplateData(r))
} }
func (wc *WebController) serveUnavailableForLegalReasons(w http.ResponseWriter, r *http.Request) { func (wc *WebController) serveUnavailableForLegalReasons(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnavailableForLegalReasons) w.WriteHeader(http.StatusUnavailableForLegalReasons)
wc.templates.Run(w, r, "451", wc.newTemplateData(w, r)) wc.templates.Run(w, r, "451", wc.newTemplateData(r))
} }

View File

@@ -14,20 +14,15 @@ import (
"time" "time"
"fornaxian.tech/log" "fornaxian.tech/log"
"fornaxian.tech/pixeldrain_api_client/pixelapi"
"fornaxian.tech/util" "fornaxian.tech/util"
) )
// TemplateData is a struct that every template expects when being rendered. In // TemplateData is a struct that every template expects when being rendered. In
// the field Other you can pass your own template-specific variables. // the field Other you can pass your own template-specific variables.
type TemplateData struct { type TemplateData struct {
tpm *TemplateManager tpm *TemplateManager
Authenticated bool APIEndpoint template.URL
User pixelapi.UserInfo Hostname template.HTML
UserAgent string
APIEndpoint template.URL
PixelAPI pixelapi.PixelAPI
Hostname template.HTML
// Only used on file viewer page // Only used on file viewer page
Title string Title string
@@ -37,56 +32,12 @@ type TemplateData struct {
URLQuery url.Values URLQuery url.Values
} }
func (wc *WebController) newTemplateData(w http.ResponseWriter, r *http.Request) (t *TemplateData) { func (wc *WebController) newTemplateData(r *http.Request) (t *TemplateData) {
t = &TemplateData{ t = &TemplateData{
tpm: wc.templates, tpm: wc.templates,
Authenticated: false, APIEndpoint: template.URL(wc.config.APIURLExternal),
UserAgent: r.UserAgent(), Hostname: template.HTML(wc.hostname),
APIEndpoint: template.URL(wc.config.APIURLExternal), URLQuery: r.URL.Query(),
// Use the user's IP address for making requests
PixelAPI: wc.api.RealIP(util.RemoteAddress(r)).RealAgent(r.UserAgent()),
Hostname: template.HTML(wc.hostname),
URLQuery: r.URL.Query(),
}
// If the user is authenticated we'll indentify him and put the user info
// into the templatedata. This is used for putting the username in the menu
// and stuff like that
if key, err := wc.getAPIKey(r); err == nil {
t.PixelAPI = t.PixelAPI.Login(key) // Use the user's API key for all requests
if t.User, err = t.PixelAPI.GetUser(); err != nil {
// This session key doesn't work, or the backend is down, user
// cannot be authenticated
log.Debug("Session check for key '%s' failed: %s", key, err)
if err.Error() == "authentication_required" || err.Error() == "authentication_failed" {
// Disable API authentication
t.PixelAPI = wc.api.RealIP(util.RemoteAddress(r)).RealAgent(r.UserAgent())
// Remove the authentication cookie
log.Debug("Deleting invalid API key")
http.SetCookie(w, &http.Cookie{
Name: "pd_auth_key",
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
Domain: wc.config.SessionCookieDomain,
})
http.SetCookie(w, &http.Cookie{
Name: "pd_auth_key",
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
Domain: ".nova.storage",
})
}
return t
}
// Authentication succeeded
t.Authenticated = true
} }
return t return t

View File

@@ -87,7 +87,7 @@ func New(r *httprouter.Router, prefix string, conf Config) (wc *WebController) {
if conf.MaintenanceMode { if conf.MaintenanceMode {
r.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
wc.templates.Run(w, r, "maintenance", wc.newTemplateData(w, r)) wc.templates.Run(w, r, "maintenance", wc.newTemplateData(r))
}) })
return wc return wc
} }
@@ -118,7 +118,7 @@ func New(r *httprouter.Router, prefix string, conf Config) (wc *WebController) {
} }
r.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
wc.serveTemplate("wrap", handlerOpts{})(w, r, nil) wc.serveTemplate("wrap")(w, r, nil)
}) })
// Request method shorthands. These help keep the array of handlers aligned // Request method shorthands. These help keep the array of handlers aligned
@@ -130,10 +130,10 @@ func New(r *httprouter.Router, prefix string, conf Config) (wc *WebController) {
path string // The URL path this API will be registered on path string // The URL path this API will be registered on
handler httprouter.Handle // The function to run when this API is called handler httprouter.Handle // The function to run when this API is called
}{ }{
{GET, "api" /* */, wc.serveMarkdown("api.md", handlerOpts{})}, {GET, "api" /* */, wc.serveMarkdown("api.md")},
{GET, "d/*path" /* */, wc.serveDirectory}, {GET, "d/*path" /* */, wc.serveDirectory},
{GET, "widgets" /* */, wc.serveTemplate("widgets", handlerOpts{})}, {GET, "widgets" /* */, wc.serveTemplate("widgets")},
{GET, "status" /* */, wc.serveTemplate("status", handlerOpts{})}, {GET, "status" /* */, wc.serveTemplate("status")},
{GET, "theme.css", wc.themeHandler}, {GET, "theme.css", wc.themeHandler},
} { } {
r.Handle(h.method, prefix+"/"+h.path, middleware(h.handler)) r.Handle(h.method, prefix+"/"+h.path, middleware(h.handler))
@@ -165,43 +165,27 @@ func middleware(handle httprouter.Handle) httprouter.Handle {
} }
} }
type handlerOpts struct { func (wc *WebController) serveTemplate(tpl string) httprouter.Handle {
Auth bool
NoEmbed bool
NoExec bool
}
func (wc *WebController) serveTemplate(tpl string, opts handlerOpts) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
if opts.NoEmbed { // if opts.NoEmbed {
w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Frame-Options", "DENY")
} // }
var td = wc.newTemplateData(w, r) err := wc.templates.Run(w, r, tpl, wc.newTemplateData(r))
if opts.Auth && !td.Authenticated {
http.Redirect(w, r, "/login?redirect="+url.QueryEscape(r.URL.Path), http.StatusSeeOther)
return
}
err := wc.templates.Run(w, r, tpl, td)
if err != nil && !util.IsNetError(err) { if err != nil && !util.IsNetError(err) {
log.Error("Error executing template '%s': %s", tpl, err) log.Error("Error executing template '%s': %s", tpl, err)
} }
} }
} }
func (wc *WebController) serveMarkdown(tpl string, opts handlerOpts) httprouter.Handle { func (wc *WebController) serveMarkdown(tpl string) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
var err error var err error
if opts.NoEmbed { // if opts.NoEmbed {
w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Frame-Options", "DENY")
} // }
var tpld = wc.newTemplateData(w, r) var tpld = wc.newTemplateData(r)
if opts.Auth && !tpld.Authenticated {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Execute the raw markdown template and save the result in a buffer // Execute the raw markdown template and save the result in a buffer
var tplBuf bytes.Buffer var tplBuf bytes.Buffer