Compare commits

...

17 Commits

Author SHA1 Message Date
99ab34c76d Fix error popup when canceling a sharing action
Fix navigator not reloading when saving an edited file
Remove dead links
Remove references to Patreon
2026-08-05 22:18:57 +02:00
4c57a363b2 Fix:
* Breadcrumbs too wide in deeply nested directories
* Sharedialog copies link and calls navigator.share at the same time
* Link copy feedback is not clear
* Show embed code when public read
* Hide hidden files from audio playlist
2026-08-05 21:54:49 +02:00
00b40ab900 Add filesystem indexing metrics 2026-08-05 21:52:28 +02:00
b02e88eb1b Add button to remove peers 2026-08-04 14:38:36 +02:00
196845d739 Remove unused Go templates 2026-08-04 13:37:59 +02:00
a12424ef66 Add configurable columns to directory view 2026-08-04 13:37:44 +02:00
f3522f370f Overhaul trash page 2026-08-03 23:45:37 +02:00
912fe06ff6 Add documentation link in menu 2026-08-03 23:31:54 +02:00
0acc6a91bb Update sharing settings 2026-08-03 23:31:43 +02:00
5b25f21f72 Update home page a bit 2026-07-30 23:15:00 +02:00
1536b8bd1b Block sharing if subscription does not support it 2026-07-30 23:13:55 +02:00
263f959565 Fix uploader not working because window.user was removed 2026-07-30 21:33:45 +02:00
609dfc906a Redesign image viewer with Claude 2026-07-30 21:33:26 +02:00
39e0019ce3 Move error handling for filesystem to frontend 2026-07-30 21:31:35 +02:00
e88b137d2b Rename FilesystemAPI back because apparently that's required 2026-07-30 17:18:46 +02:00
4b8bd11a4c UI overhaul, import fixes, fix user store 2026-07-30 17:07:46 +02:00
773149169b Rename fnx_web to nova_web 2026-07-30 15:48:10 +02:00
73 changed files with 1922 additions and 1471 deletions

14
go.mod
View File

@@ -1,8 +1,9 @@
module fornaxian.tech/fnx_web 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

@@ -1,50 +0,0 @@
{{define "email_confirm"}}<!DOCTYPE html>
<html lang="en">
<head>
{{template "meta_tags" "E-mail verification"}}
</head>
<body>
{{template "page_top" .}}
{{if eq .Other "success"}}
<header>
<h1>Success!</h1>
</header>
<div id="page_content" class="page_content">
<section>
<p>
Your account's e-mail address has been updated.
</p>
</section>
</div>
{{else if eq .Other "not_found"}}
<header>
<h1>E-mail change failed</h1>
</header>
<div id="page_content" class="page_content">
<section>
<p>
This e-mail change request does not exist or has expired.
Please try again if you still want to change your e-mail
address.
</p>
</section>
</div>
{{else}}
<header>
<h1>Error</h1>
</header>
<div id="page_content" class="page_content">
<section>
<p>
Something went wrong while processing this request. Please
try again later.
</p>
</section>
</div>
{{end}}
{{template "page_bottom" .}}
</body>
</html>
{{end}}

View File

@@ -1,90 +0,0 @@
{{define "menu"}}
<button id="button_toggle_navigation" class="button_toggle_navigation icon" onclick="toggleMenu();">
menu
</button>
<nav id="page_navigation" class="page_navigation">
<a href="/home#">Home</a>
{{if eq .User.Subscription.ID ""}}
<a href="/home#pro">Get Premium</a>
{{end}}
<hr />
{{if .Authenticated}}
<a href="/user">{{.User.Username}}</a>
{{if .User.Subscription.FilesystemAccess}}
<a href="/d/me">Filesystem</a>
{{end}}
{{if .User.IsAdmin}}
<a href="/admin">Admin Panel</a>
{{end}}
<a href="/logout">Log out</a>
{{else}}
<a href="/login">Login</a>
<a href="/register">Register</a>
{{end}}
<hr />
<a href="/about">Questions & Answers</a>
<a href="/apps">Apps</a>
<a href="/appearance">Theme</a>
<a href="/speedtest">Speedtest</a>
<a href="/api">API</a>
<a href="/filesystem">Filesystem Guide</a>
<a href="/acknowledgements">Acknowledgements</a>
<a href="/abuse">DMCA and abuse</a>
<a href="https://stats.uptimerobot.com/p9v2ktzyjm" target="_blank">Server Status</a>
</nav>
<script>
function toggleMenu() {
var nav = document.getElementById("page_navigation");
var body = document.getElementById("page_body");
if (nav.offsetLeft === 0) {
// Menu is visible, hide it
nav.style.left = -nav.offsetWidth + "px";
body.style.marginLeft = "0";
} else {
// Menu is hidden, show it
nav.style.left = "0";
body.style.marginLeft = nav.offsetWidth + "px";
}
}
function resetMenu() {
document.getElementById("page_navigation").style.left = "";
document.getElementById("page_body").style.marginLeft = "";
}
</script>
{{end}}
{{define "footer"}}
<footer>
<div class="footer_content">
<div style="display: inline-block; margin: 0 8px;">
Nova is a product by <a href="//fornaxian.tech" target="_blank">Fornaxian Technologies</a>
</div>
<br/>
<div style="display: inline-block; margin: 0 8px;">
<a href="https://github.com/Fornaxian" target="_blank">{{template `github.svg` .}} GitHub</a> |
<a href="https://mastodon.social/web/@fornax" target="_blank">{{template `mastodon.svg` .}} Mastodon</a>
</div>
<br/>
<div style="display: inline-block; margin: 0 8px;">
{{$speed := .PixelAPI.GetMiscClusterSpeed}}
Server speed: {{ formatDataBits $speed.ServerTX }}ps |
Cache cluster: {{ formatDataBits $speed.CacheTX }}ps |
Storage cluster: {{ formatDataBits $speed.StorageTX }}ps
</div>
<br/>
<span class="small_footer_text" style="font-size: .75em; line-height: .75em;">
page rendered by {{.Hostname}}
</span>
</div>
</footer>
{{end}}
{{define "page_top"}}
{{template "menu" .}}
<div id="page_body" class="page_body">
{{end}}
{{define "page_bottom"}}
{{template "footer" .}}
</div>
{{end}}

View File

@@ -1,40 +0,0 @@
{{define "logout"}}<!DOCTYPE html>
<html lang="en">
<head>
{{template "meta_tags" "Logging out..."}}
</head>
<body>
{{template "page_top" .}}
<header>
<h1>Logging out of your Nova account</h1>
</header>
<div id="page_content" class="page_content">
<br/>
<form method="POST" action="/logout">
<button role="submit" class="button_highlight">
<i class="icon">logout</i>
Log out of Nova on this computer
</button>
</form>
<br/>
<section>
<h2>Why do I need to confirm my logout?</h2>
<p>
We need you to confirm your action so we can be sure that you
really requested a logout. If we didn't do this, anyone (or any
website) would be able to send you to this page and you would
automatically get logged out of Nova, which would be very
annoying.
</p>
<p>
To prevent this from happening we're verifying that you actually
want to log out by making you click this button. Because this
logout button triggers a different request type than normal
page visit we can confirm that you really want to log out.
</p>
</section>
</div>
{{template "page_bottom" .}}
</body>
</html>
{{end}}

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

@@ -26,7 +26,26 @@ const groups: {
{metric: "api_error_400", data_type: "number"}, {metric: "api_error_400", data_type: "number"},
{metric: "api_error_500", data_type: "number"}, {metric: "api_error_500", data_type: "number"},
{metric: "api_panic", data_type: "number"}, {metric: "api_panic", data_type: "number"},
{metric: "api_user_register", data_type: "number"},
{metric: "api_user_login", data_type: "number"},
{metric: "api_user_delete", data_type: "number"},
], ],
}, {
title: "Filesystem API", expanded: false,
graphs: [
{metric: "filesystem_write", data_type: "number"},
{metric: "filesystem_write_size", data_type: "bytes"},
{metric: "filesystem_read", data_type: "number"},
{metric: "filesystem_read_size", data_type: "bytes"},
{metric: "filesystem_index_update", data_type: "number"},
{metric: "filesystem_index_update_duration", data_type: "duration"},
{
metric: "filesystem_index_update_duration_avg",
agg_base: "filesystem_index_update_duration",
agg_divisor: "filesystem_index_update",
data_type: "duration",
},
]
}, { }, {
title: "Task scheduler", expanded: false, title: "Task scheduler", expanded: false,
graphs: [ graphs: [

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

@@ -2,6 +2,8 @@
import { flip } from "svelte/animate"; import { flip } from "svelte/animate";
import { formatDataVolume } from "util/Formatting"; import { formatDataVolume } from "util/Formatting";
import SortButton from "layout/SortButton.svelte"; import SortButton from "layout/SortButton.svelte";
import { loading_run } from "lib/Loading";
import { admin_decommission_node } from "lib/AdminAPI";
let { let {
peers = $bindable([]) peers = $bindable([])
@@ -33,6 +35,11 @@ let {
}[] }[]
} = $props(); } = $props();
// If not all peers are online we show the decommission button
let all_reachable = $derived(peers.reduce((acc, val) => {
return val.reachable ? acc : false
}, true))
$effect(() => { $effect(() => {
for (let peer of peers) { for (let peer of peers) {
peer.avg_network_total = peer.avg_network_tx + peer.avg_network_rx peer.avg_network_total = peer.avg_network_tx + peer.avg_network_rx
@@ -74,6 +81,16 @@ let sort = (field: string) => {
}) })
peers = peers peers = peers
} }
const decommission = (id: string) => {
loading_run(async () => {
try {
await admin_decommission_node(id)
} catch (err) {
alert(JSON.stringify(err))
}
})
}
</script> </script>
<div class="table_scroll"> <div class="table_scroll">
@@ -92,16 +109,17 @@ let sort = (field: string) => {
<td><SortButton field="cache_threshold" active_field={sort_field} asc={asc} sort_func={sort}>CThresh</SortButton></td> <td><SortButton field="cache_threshold" active_field={sort_field} asc={asc} sort_func={sort}>CThresh</SortButton></td>
<td><SortButton field="free_space" active_field={sort_field} asc={asc} sort_func={sort}>Free</SortButton></td> <td><SortButton field="free_space" active_field={sort_field} asc={asc} sort_func={sort}>Free</SortButton></td>
<td><SortButton field="min_free_space" active_field={sort_field} asc={asc} sort_func={sort}>Min free</SortButton></td> <td><SortButton field="min_free_space" active_field={sort_field} asc={asc} sort_func={sort}>Min free</SortButton></td>
{#if !all_reachable}<td></td>{/if}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each peers as peer (peer.ip)} {#each peers as peer (peer.id)}
<tr style="border: none;" <tr style="border: none;"
class:highlight_red={!peer.reachable} class:highlight_red={!peer.reachable}
class:highlight_yellow={peer.free_space < peer.min_free_space / 2} class:highlight_yellow={peer.free_space < peer.min_free_space / 2}
class:highlight_blue={peer.free_space < peer.min_free_space} class:highlight_blue={peer.free_space < peer.min_free_space}
class:highlight_green={peer.reachable} class:highlight_green={peer.reachable}
animate:flip={{duration: 1000}} animate:flip={{duration: 500}}
> >
<td>{peer.hostname}</td> <td>{peer.hostname}</td>
<td>{peer.unreachable_count}</td> <td>{peer.unreachable_count}</td>
@@ -115,6 +133,13 @@ let sort = (field: string) => {
<td>{formatDataVolume(peer.cache_threshold, 3)}</td> <td>{formatDataVolume(peer.cache_threshold, 3)}</td>
<td>{formatDataVolume(peer.free_space, 3)}</td> <td>{formatDataVolume(peer.free_space, 3)}</td>
<td>{formatDataVolume(peer.min_free_space, 3)}</td> <td>{formatDataVolume(peer.min_free_space, 3)}</td>
{#if !all_reachable}
<td>
<button class="button flat delete" onclick={() => decommission(peer.id)}>
<i class="icon small">delete</i>
</button>
</td>
{/if}
</tr> </tr>
{/each} {/each}
@@ -135,6 +160,7 @@ let sort = (field: string) => {
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.cache_threshold, 0), 4)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.cache_threshold, 0), 4)}</td>
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.free_space, 0), 4)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.free_space, 0), 4)}</td>
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.min_free_space, 0), 3)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.min_free_space, 0), 3)}</td>
{#if !all_reachable}<td></td>{/if}
</tr> </tr>
<tr> <tr>
<td>Average</td> <td>Average</td>
@@ -153,6 +179,7 @@ let sort = (field: string) => {
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.cache_threshold, 0) / peers.length, 4)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.cache_threshold, 0) / peers.length, 4)}</td>
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.free_space, 0) / peers.length, 4)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.free_space, 0) / peers.length, 4)}</td>
<td>{formatDataVolume(peers.reduce((acc, val) => acc += val.min_free_space, 0) / peers.length, 3)}</td> <td>{formatDataVolume(peers.reduce((acc, val) => acc += val.min_free_space, 0) / peers.length, 3)}</td>
{#if !all_reachable}<td></td>{/if}
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -167,4 +194,7 @@ let sort = (field: string) => {
tr { tr {
text-align: initial; text-align: initial;
} }
.delete {
margin: 0;
}
</style> </style>

View File

@@ -36,7 +36,7 @@ onMount(() => {
{:else if node.is_shared()} {:else if node.is_shared()}
<i class="icon small">share</i> <i class="icon small">share</i>
{/if} {/if}
<div class="node_name" class:base={$nav.base_index === i}> <div class="node_name" class:base={$nav.base_index === i} class:small={$nav.path.length > 10}>
{node.name} {node.name}
</div> </div>
</a> </a>
@@ -64,6 +64,9 @@ onMount(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.small {
max-width: 10vw;
}
.base { .base {
/* The base name uses all available space */ /* The base name uses all available space */
max-width: unset; max-width: unset;

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
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";
@@ -19,12 +18,13 @@ let {
export const toggle = () => visible = !visible export const toggle = () => visible = !visible
const visibility_change = visible => { $effect(() => {
// Only update the chart if the details modal is open. Else the chart is
// rendered for nothing
if (visible) { if (visible) {
update_chart(nav.base, 0, 0) update_chart($nav.base, chart_timespan, chart_interval)
} }
} })
let chart: Chart = $state() let chart: Chart = $state()
let chart_timespan = $state(0) let chart_timespan = $state(0)
@@ -134,15 +134,9 @@ let update_chart = async (base: FSNode, timespan: number, interval: number) => {
console.error("Failed to get time series data:", err) console.error("Failed to get time series data:", err)
} }
} }
run(() => {
visibility_change(visible)
});
let direct_url = $derived($nav.base.path ? window.location.origin+fs_path_url($nav.base.path) : "") let direct_url = $derived($nav.base.path ? window.location.origin+fs_path_url($nav.base.path) : "")
let share_url = $derived(fs_share_url($nav.path)) let share_url = $derived(fs_share_url($nav.path))
let direct_share_url = $derived(fs_share_hotlink_url($nav.path)) let direct_share_url = $derived(fs_share_hotlink_url($nav.path))
run(() => {
update_chart($nav.base, chart_timespan, chart_interval)
});
</script> </script>
<Modal bind:visible={visible} title="Details" width={($nav.base.type === "file" ? 1000 : 750) + "px"}> <Modal bind:visible={visible} title="Details" width={($nav.base.type === "file" ? 1000 : 750) + "px"}>
@@ -172,13 +166,6 @@ run(() => {
<td>Mode</td> <td>Mode</td>
<td>{$nav.base.mode_string}</td> <td>{$nav.base.mode_string}</td>
</tr> </tr>
<tr>
<td>Node ID</td>
<td>
<CopyButton text={$nav.base.id}>Copy</CopyButton>
<a href="/d/{$nav.base.id}">{$nav.base.id}</a>
</td>
</tr>
{#if $nav.base.type === "file"} {#if $nav.base.type === "file"}
<tr> <tr>
<td>File type</td> <td>File type</td>
@@ -201,7 +188,23 @@ run(() => {
</td> </td>
</tr> </tr>
<tr><td>SHA256 sum</td><td>{$nav.base.sha256_sum}</td></tr> <tr><td>SHA256 sum</td><td>{$nav.base.sha256_sum}</td></tr>
{:else if $nav.base.type === "dir"}
<tr>
<td>Total directory size</td>
<td>{formatDataVolume($nav.base.recursive_size, 4)}</td>
</tr>
<tr>
<td>Total directory children</td>
<td>{formatThousands($nav.base.recursive_children)}</td>
</tr>
{/if} {/if}
<tr>
<td>Node ID</td>
<td>
<CopyButton text={$nav.base.id}>Copy</CopyButton>
<a href="/d/{$nav.base.id}">{$nav.base.id}</a>
</td>
</tr>
<tr> <tr>
<td>Direct link</td> <td>Direct link</td>
<td> <td>

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import TextBlock from "layout/TextBlock.svelte";
import { type FSNavigator } from "./FSNavigator";
import { onMount } from "svelte";
import { breadcrumbs_store } from "wrap/BreadcrumbStore";
let {
nav
}: {
nav: FSNavigator;
} = $props();
onMount(() => {
return nav.subscribe(nav => {
breadcrumbs_store.set(breadcrumbs)
})
})
</script>
{#snippet breadcrumbs()}
{$nav.navigation_error}
{/snippet}
<TextBlock>
{#if $nav.navigation_error === "not_found" || $nav.navigation_error === "path_not_found"}
<h1>Page not found</h1>
<p>
This page could not be found.
</p>
{:else if $nav.navigation_error === "permission_denied" || $nav.navigation_error === "forbidden"}
<h1>Permission denied</h1>
<p>
You are not allowed to access this resource.
</p>
{:else if $nav.navigation_error === "unavailable_for_legal_reasons"}
<h1>Unavailable for legal reasons</h1>
<p>
This content has been removed for breaking the law.
</p>
{:else if $nav.navigation_error === "out_of_transfer"}
<h1>Traffic limit exceeded</h1>
<p>
The host user has exceeded the traffic limit of their subscription.
They need to upgrade to a higher subscription tier.
</p>
{:else}
<h1>Unknown error code</h1>
<p>
{$nav.navigation_error}
</p>
{/if}
</TextBlock>

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.svelte";
import type { FSNode, FSPath, FSPermissions, FSContext } from "../lib/FilesystemAPI.svelte"; import type { FSNode, FSPath, FSPermissions, FSContext } from "lib/FilesystemAPI.svelte";
export class FSNavigator { export class FSNavigator {
// Parts of the raw API response // Parts of the raw API response
@@ -54,6 +54,7 @@ export class FSNavigator {
} }
last_requested_path: string = "" last_requested_path: string = ""
navigation_error: string = ""
navigate = async (path: string, push_history: boolean) => { navigate = async (path: string, push_history: boolean) => {
if (path === this.last_requested_path) { if (path === this.last_requested_path) {
console.debug("FSNavigator: Requested path ", path, " is equal to current path. Debouncing") console.debug("FSNavigator: Requested path ", path, " is equal to current path. Debouncing")
@@ -72,14 +73,14 @@ export class FSNavigator {
const resp = await fs_get_node(path) const resp = await fs_get_node(path)
this.open_node(resp, push_history) this.open_node(resp, push_history)
} catch (err: any) { } catch (err: any) {
if (err.value && err.value === "path_not_found") { if (err.value !== undefined && err.value === "path_not_found") {
if (path !== this.path[0].path && path !== "/" && path !== "") { if (path !== this.path[0].path && path !== "/" && path !== "") {
console.debug("Path", path, "was not found, trying to navigate to parent") console.debug("Path", path, "was not found, trying to navigate to parent")
this.navigate(fs_split_path(path).parent, push_history) this.navigate(fs_split_path(path).parent, push_history)
} }
} else if (err.message) { } else if (err.value !== undefined) {
console.error(err) this.navigation_error = err.value
alert("Error: " + err.message) this.notify_subscribers()
} else { } else {
console.error(err) console.error(err)
alert("Error: " + err) alert("Error: " + err)
@@ -133,6 +134,7 @@ export class FSNavigator {
this.children = node.children this.children = node.children
this.permissions = node.permissions this.permissions = node.permissions
this.context = node.context this.context = node.context
this.navigation_error = "" // Clear the error value
this.initialized = true this.initialized = true
console.debug("Opened node", node) console.debug("Opened node", node)
@@ -268,9 +270,14 @@ const sort_children = (children: FSNode[], field: string, asc: boolean) => {
[a, b] = [b, a] [a, b] = [b, a]
} }
// If the two values are equal then we force sort by name, since names if (field === "size") {
// are always unique // When doing a size comparison we also take the recursive directory
if (a[field] === b[field]) { // size into account
return (a.type === "dir" ? a.recursive_size : a.file_size) -
(b.type === "dir" ? b.recursive_size : b.file_size)
} else if (a[field] === b[field]) {
// If the two values are equal then we force sort by name, since
// names are always unique
return a.name.localeCompare(b.name, undefined, { numeric: true }) return a.name.localeCompare(b.name, undefined, { numeric: true })
} else if (typeof (a[field]) === "number") { } else if (typeof (a[field]) === "number") {
// Sort ints from high to low // Sort ints from high to low

View File

@@ -11,6 +11,7 @@ import { css_from_path } from "filesystem/edit_window/Branding";
import AffiliatePrompt from "user_home/AffiliatePrompt.svelte"; import AffiliatePrompt from "user_home/AffiliatePrompt.svelte";
import { current_page_store } from "wrap/RouterStore"; import { current_page_store } from "wrap/RouterStore";
import SearchBar from "./SearchBar.svelte"; import SearchBar from "./SearchBar.svelte";
import ErrorPage from "./ErrorPage.svelte";
let file_preview: FilePreview = $state() let file_preview: FilePreview = $state()
let toolbar: Toolbar = $state() let toolbar: Toolbar = $state()
@@ -141,6 +142,7 @@ const keydown = (e: KeyboardEvent) => {
<svelte:window onkeydown={keydown} /> <svelte:window onkeydown={keydown} />
{#if $nav.navigation_error === ""}
<div class="filesystem"> <div class="filesystem">
<Breadcrumbs nav={nav} edit_window={edit_window}/> <Breadcrumbs nav={nav} edit_window={edit_window}/>
@@ -164,6 +166,10 @@ const keydown = (e: KeyboardEvent) => {
/> />
</div> </div>
{:else}
<ErrorPage nav={nav} />
{/if}
<SearchBar bind:this={search_bar} nav={nav}/> <SearchBar bind:this={search_bar} nav={nav}/>
<DetailsWindow nav={nav} bind:this={details_window} bind:visible={details_visible} /> <DetailsWindow nav={nav} bind:this={details_window} bind:visible={details_visible} />

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,10 +1,11 @@
<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_check_is_error, fs_node_icon, fs_share_hotlink_url, fs_share_url, fs_update, type FSNode, type FSPermissions } from "lib/FilesystemAPI.svelte";
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";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { get_user } from "lib/NovaAPI";
import Button from "layout/Button.svelte";
let { nav }: { let { nav }: {
nav: FSNavigator; nav: FSNavigator;
@@ -12,20 +13,38 @@ let { nav }: {
let path: FSNode[] let path: FSNode[]
let base: FSNode = $state() let base: FSNode = $state()
let toast = $state("")
let share_url = $state("") let share_url = $state("")
let direct_share_url = $state("") let direct_share_url = $state("")
let is_parent = $state(false)
let parent_node: FSNode = $state() let parent_node: FSNode = $state()
let dialog: Dialog = $state() let dialog: Dialog = $state()
let dialog_not_allowed: Dialog = $state()
export const open = async (e: MouseEvent, p: FSNode[]) => { export const open = async (e: MouseEvent, p: FSNode[]) => {
path = p path = p
base = path[path.length-1] base = path[path.length-1]
const user = await get_user()
if (user.subscription.file_sharing === false && base.created_by === user.id) {
}
share_url = fs_share_url(path) share_url = fs_share_url(path)
if (share_url === "") { if (share_url === "") {
// File is not public, make public // File is not public, make public
await make_public() try {
await make_public()
} catch (err) {
if (fs_check_is_error(err, "sharing_not_allowed")) {
if (e.target instanceof HTMLButtonElement) {
dialog_not_allowed.open(e.target.getBoundingClientRect())
} else {
dialog_not_allowed.open((e.target as HTMLElement).parentElement.getBoundingClientRect())
}
return
} else {
alert("Error file making file public: "+err)
}
}
} }
await share() await share()
@@ -50,15 +69,19 @@ const make_public = async () => {
} }
} }
let share_parent = $state(false)
let navigator_share = $state(false)
const share = async () => { const share = async () => {
// If the base node does not have a public ID then the file is shared // If the base node is not shared then the file is shared through a parent
// through a parent node. In that case we ask the user if it was their // node. In that case we ask the user if it was their intention to share the
// intention to share the parent directory // parent directory
is_parent = base.id === undefined share_parent = !base.is_shared()
if (is_parent) { navigator_share = navigator.share !== undefined
if (!base.is_shared()) {
// Walk path backwards looking for the last public ID // Walk path backwards looking for the last public ID
for (let i = path.length - 1; i >= 0; i--) { for (let i = path.length - 1; i >= 0; i--) {
if (path[i].id !== undefined && path[i].id !== "me") { if (path[i].is_shared()) {
parent_node = path[i] parent_node = path[i]
break break
} }
@@ -67,35 +90,35 @@ const share = async () => {
share_url = fs_share_url(path) share_url = fs_share_url(path)
direct_share_url = fs_share_hotlink_url(path) direct_share_url = fs_share_hotlink_url(path)
}
try { const nav_share = async (url: string) => {
await navigator.share({ await navigator.share({
title: base.name, title: base.name,
text: "I would like to share '" + base.name + "' with you", text: "I would like to share '" + base.name + "' with you",
url: share_url, url: url,
}) })
} catch(_) {
if (copy_text(share_url)) {
toast = "Link copied to clipboard"
setTimeout(() => {toast = ""}, 10000)
} else {
alert("Could not copy text")
}
}
} }
</script> </script>
<Dialog bind:this={dialog_not_allowed}>
<div class="dialog_inner">
<div class="highlight_yellow" transition:fade>
Sharing is not allowed on free accounts. Upgrade to premium to
enable this feature
</div>
</div>
</Dialog>
<Dialog bind:this={dialog}> <Dialog bind:this={dialog}>
<div class="dialog_inner"> <div class="dialog_inner">
{#if toast !== ""}
<div class="highlight_green" transition:fade>{toast}</div>
<div class="separator" transition:fade></div>
{/if}
<div>Sharing link</div> <div>Sharing link</div>
<div class="link_copy"> <div class="link_copy">
<div class="button_container"> <div class="button_container">
<CopyButton text={share_url}>Copy</CopyButton> <CopyButton text={share_url}>Copy</CopyButton>
{#if navigator_share}
<Button icon="share" label="Share" click={() => {nav_share(share_url)}}/>
{/if}
</div> </div>
<a href="{share_url}">{share_url}</a> <a href="{share_url}">{share_url}</a>
</div> </div>
@@ -104,11 +127,14 @@ const share = async () => {
<div class="link_copy"> <div class="link_copy">
<div class="button_container"> <div class="button_container">
<CopyButton text={direct_share_url}>Copy</CopyButton> <CopyButton text={direct_share_url}>Copy</CopyButton>
{#if navigator_share}
<Button icon="share" label="Share" click={() => {nav_share(direct_share_url)}}/>
{/if}
</div> </div>
<a href="{direct_share_url}">{direct_share_url}</a> <a href="{direct_share_url}">{direct_share_url}</a>
</div> </div>
{#if is_parent} {#if share_parent}
<div class="separator"></div> <div class="separator"></div>
<div> <div>
This link also gives access to This link also gives access to

View File

@@ -36,11 +36,10 @@ 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}>
<div class="button_row"> <div class="button_row">
<button onclick={() => {nav.open_sibling(-1)}}> <button onclick={() => {nav.open_sibling(-1)}}>
<i class="icon">skip_previous</i> <i class="icon">skip_previous</i>
@@ -73,7 +72,11 @@ export const copy_link = () => {
{#if path_is_shared($nav.path)} {#if path_is_shared($nav.path)}
<button onclick={copy_link} class:button_highlight={link_copied}> <button onclick={copy_link} class:button_highlight={link_copied}>
<i class="icon">content_copy</i> <i class="icon">content_copy</i>
<span><u>C</u>opy link</span> {#if link_copied}
<span>Link copied!</span>
{:else}
<span><u>C</u>opy link</span>
{/if}
</button> </button>
{/if} {/if}
@@ -110,11 +113,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

@@ -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

@@ -5,9 +5,8 @@ import BrandingOptions from "./BrandingOptions.svelte";
import { branding_from_props } from "./Branding"; import { branding_from_props } from "./Branding";
import FileOptions from "./FileOptions.svelte"; import FileOptions from "./FileOptions.svelte";
import SharingOptions from "./SharingOptions.svelte"; import SharingOptions from "./SharingOptions.svelte";
import AccessControl from "./AccessControl.svelte";
import type { FSNavigator } from "filesystem/FSNavigator"; import type { FSNavigator } from "filesystem/FSNavigator";
import { loading_finish, loading_start } from "lib/Loading"; import { loading_run } from "lib/Loading";
let file: FSNode = $state({} as FSNode) let file: FSNode = $state({} as FSNode)
let options: NodeOptions = $state({} as NodeOptions) let options: NodeOptions = $state({} as NodeOptions)
@@ -73,23 +72,22 @@ const save = async (e: SubmitEvent) => {
e.preventDefault() e.preventDefault()
console.debug("Saving file", file.path) console.debug("Saving file", file.path)
let new_file: FSNode
try { try {
loading_start() await loading_run(async () => {
options.branding_enabled = JSON.stringify(branding_enabled) options.branding_enabled = JSON.stringify(branding_enabled)
new_file = await fs_update(file.path, options) await fs_update(file.path, options)
if (new_name !== file.name) { // If the file name has changed we need to call fs_rename. This is a
let parent = file.path.slice(0, -file.name.length) // separate operation because a rename also moves the file
console.log("Moving", file.path, "to", parent+new_name) if (new_name !== file.name) {
let parent = file.path.slice(0, -file.name.length)
console.log("Moving", file.path, "to", parent+new_name)
await fs_rename(file.path, parent+new_name) await fs_rename(file.path, parent+new_name)
file.path = parent+new_name file.path = parent+new_name
}
new_file.name = new_name })
new_file.path = file.path
}
} catch (err) { } catch (err) {
if (err.message) { if (err.message) {
alert(err.message) alert(err.message)
@@ -98,11 +96,11 @@ const save = async (e: SubmitEvent) => {
alert(err) alert(err)
} }
return return
} finally {
loading_finish()
} }
if (open_after_edit) { // The navigate call will be debounced if the new path is equal to the
// current path. So we call reload() instead if that's the case
if (open_after_edit && $nav.base.path !== file.path) {
nav.navigate(file.path, false) nav.navigate(file.path, false)
} else { } else {
nav.reload() nav.reload()
@@ -120,12 +118,6 @@ const save = async (e: SubmitEvent) => {
<i class="icon">share</i> <i class="icon">share</i>
Sharing Sharing
</button> </button>
{#if $nav.permissions.owner}
<button class:button_highlight={tab === "access"} onclick={() => tab = "access"}>
<i class="icon">key</i>
Access control
</button>
{/if}
<button class:button_highlight={tab === "branding"} onclick={() => tab = "branding"}> <button class:button_highlight={tab === "branding"} onclick={() => tab = "branding"}>
<i class="icon">palette</i> <i class="icon">palette</i>
Branding Branding
@@ -148,8 +140,6 @@ const save = async (e: SubmitEvent) => {
bind:file bind:file
bind:options bind:options
/> />
{:else if tab === "access"}
<AccessControl bind:options />
{:else if tab === "branding"} {:else if tab === "branding"}
<BrandingOptions <BrandingOptions
bind:enabled={branding_enabled} bind:enabled={branding_enabled}

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
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.svelte";
import AccessControl from "./AccessControl.svelte"; import AccessControl from "./AccessControl.svelte";
import { user } from "lib/UserStore";
let { let {
file = $bindable(), file = $bindable(),
@@ -14,27 +14,24 @@ let {
options: NodeOptions; options: NodeOptions;
} = $props(); } = $props();
let embed_html: string = $state() let is_shared = $derived(file.is_shared() || options.link_permissions?.read)
let preview_area: HTMLDivElement = $state() let embed_html: string = $derived.by(() => {
if (!is_shared) {
const embed_iframe = (file: FSNode, options: NodeOptions) => { return "File is not shared, can't generate embed code"
if (!file.is_shared()) {
example = false
embed_html = "File is not shared, can't generate embed code"
return
} }
let url = domain_url()+"/d/"+file.id let url = domain_url()+"/d/"+file.id
embed_html = `<iframe ` + return `<iframe ` +
`src="${url}" ` + `src="${url}" ` +
`style="border: none; width: 100%; max-width 90vw; height: 800px; max-height: 75vh; border-radius: 6px;" ` + `style="border: none; width: 100%; max-width 90vw; height: 800px; max-height: 75vh; border-radius: 6px;" ` +
`allowfullscreen` + `allowfullscreen` +
`></iframe>` `></iframe>`
} })
let preview_area: HTMLDivElement = $state()
let example = $state(false) let example = $state(false)
const toggle_example = () => { const toggle_example = () => {
if (file.is_shared()) { if (is_shared) {
example = !example example = !example
if (example) { if (example) {
preview_area.innerHTML = embed_html preview_area.innerHTML = embed_html
@@ -45,11 +42,15 @@ const toggle_example = () => {
} }
let share_link = $derived(window.location.protocol+"//"+window.location.host+"/d/"+file.id) let share_link = $derived(window.location.protocol+"//"+window.location.host+"/d/"+file.id)
run(() => {
embed_iframe(file, options)
});
</script> </script>
{#if $user.subscription.file_sharing === false}
<div class="highlight_yellow">
Your account does not support sharing features. Access controls will not
have any effect until you upgrade to premium.
</div>
{/if}
<fieldset> <fieldset>
<legend>Public link</legend> <legend>Public link</legend>
@@ -85,8 +86,8 @@ run(() => {
<div class="center"> <div class="center">
<textarea bind:value={embed_html} style="width: 100%; height: 4em;"></textarea> <textarea bind:value={embed_html} style="width: 100%; height: 4em;"></textarea>
<br/> <br/>
<CopyButton text={embed_html}>Copy HTML</CopyButton> <CopyButton text={embed_html} disabled={!is_shared}>Copy HTML</CopyButton>
<button onclick={toggle_example} class:button_highlight={example} disabled={!file.is_shared()}> <button onclick={toggle_example} class:button_highlight={example} disabled={!is_shared}>
<i class="icon">visibility</i> Show example <i class="icon">visibility</i> Show example
</button> </button>
</div> </div>

View File

@@ -86,9 +86,17 @@ const themes = [
brand_background_color: "#1e1e2e", brand_background_color: "#1e1e2e",
brand_body_color: "#181825", brand_body_color: "#181825",
brand_card_color: "#313244", brand_card_color: "#313244",
}, {
name: "Dracula",
brand_input_color: "#424450",
brand_highlight_color: "#50FA7B",
brand_danger_color: "#FF5555",
brand_background_color: "#191A21",
brand_body_color: "#21222C",
brand_card_color: "#343746",
}, { }, {
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

@@ -101,21 +101,6 @@ const navigate_back = () => {
// Deletion function // Deletion function
const delete_selected = async () => { const delete_selected = async () => {
let count = nav.children.reduce((acc, cur) => {
if (cur.fm_selected) {
acc++
}
return acc
}, 0)
let confirmSingle = `Are you sure you want to delete this file? This action is irreversible.`
let confirmMulti = `Are you sure you want to delete these ${count} files? This action is irreversible.`
if (count === 0 ||
(count === 1 && !confirm(confirmSingle)) ||
(count > 1 && !confirm(confirmMulti))) {
return
}
try { try {
loading_start() loading_start()
@@ -305,7 +290,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 +415,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

@@ -1,9 +1,13 @@
<script lang="ts"> <script lang="ts">
import { formatDataVolume } from "util/Formatting"; import { formatDataVolume, formatDate, formatThousands } from "util/Formatting";
import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte" import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte"
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";
import Dialog from "layout/Dialog.svelte";
import Button from "layout/Button.svelte";
import { onMount } from "svelte";
import CopyButton from "layout/CopyButton.svelte";
let { let {
nav, nav,
@@ -20,23 +24,70 @@ let {
hide_edit?: boolean hide_edit?: boolean
hide_branding?: boolean hide_branding?: boolean
} = $props(); } = $props();
let dialog: Dialog = $state()
const columns: {field: string, label: string}[] = [
{field: "modified", label: "Modified on"},
{field: "created", label: "Created on"},
{field: "created_by", label: "Created by"},
{field: "mode", label: "Mode"},
{field: "recursive_children", label: "Children"},
{field: "file_type", label: "File type"},
{field: "size", label: "Size"},
{field: "id", label: "Node ID"},
]
let config: {
modified?: boolean
created?: boolean
created_by?: boolean
mode?: boolean
recursive_children?: boolean
file_type?: boolean
size?: boolean
id?: boolean
} = $state({
size: true, // Only size is visible by default
})
const prevent = (e: MouseEvent) => {
e.stopPropagation()
}
const submit_settings = (e: SubmitEvent) => {
e.preventDefault()
dialog.close()
}
const save_settings = () => {
window.localStorage.setItem("list_view_columns", JSON.stringify(config))
}
const load_settings = () => {
let item = window.localStorage.getItem("list_view_columns")
if (item !== null) {
config = JSON.parse(item)
}
}
onMount(load_settings)
</script> </script>
<table class="directory"> <table class="directory">
<thead> <thead>
<tr> <tr class="header_row">
<td></td> <td></td>
<td> <td>
<SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="name"> <SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="name">
Name Name
</SortButton> </SortButton>
</td> </td>
<td class="hide_small"> {#each columns as col}
<SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="file_size"> <td class="hide_small" class:hidden={!config[col.field]}>
Size <SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field={col.field}>
</SortButton> {col.label}
</SortButton>
</td>
{/each}
<td class="node_icons icons_wrap">
<Button flat icon="settings" click={e => dialog.open(e)} />
</td> </td>
<td></td>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -56,11 +107,35 @@ let {
{child.name} {child.name}
</a> </a>
</td> </td>
<td class="node_size hide_small"> <td class="node_size hide_small" class:hidden={!config.modified}>
{formatDate(child.modified, true, true, false)}
</td>
<td class="node_size hide_small" class:hidden={!config.created}>
{formatDate(child.created, true, true, false)}
</td>
<td class="node_size hide_small" class:hidden={!config.created_by}>
{child.created_by}
</td>
<td class="node_size hide_small" class:hidden={!config.mode}>
{child.mode_string}
</td>
<td class="node_size hide_small" class:hidden={!config.recursive_children}>
{formatThousands(child.recursive_children)}
</td>
<td class="node_size hide_small" class:hidden={!config.file_type}>
{child.file_type}
</td>
<td class="node_size hide_small" class:hidden={!config.size}>
{#if child.type === "file"} {#if child.type === "file"}
{formatDataVolume(child.file_size, 3)} {formatDataVolume(child.file_size, 3)}
{:else if child.type === "dir"}
{formatDataVolume(child.recursive_size, 3)}
{/if} {/if}
</td> </td>
<td class="node_size hide_small" class:hidden={!config.id}>
<CopyButton text={$nav.base.id} onclick={prevent} />
<a href="/d/{child.id}" onclick={prevent}>{child.id}</a>
</td>
<td class="node_icons"> <td class="node_icons">
<div class="icons_wrap"> <div class="icons_wrap">
{#if child.abuse_type !== undefined} {#if child.abuse_type !== undefined}
@@ -91,15 +166,39 @@ let {
</tbody> </tbody>
</table> </table>
<Dialog bind:this={dialog} onclose={save_settings}>
<form class="column_config" onsubmit={submit_settings}>
{#each columns as col}
<div>
<input type="checkbox" id="show_{col.field}" name="show_{col.field}" bind:checked={config[col.field]}/>
<label for="show_{col.field}">{col.label}</label>
</div>
{/each}
<button type="submit">
<i class="icon">save</i>
Save
</button>
</form>
</Dialog>
<style> <style>
.directory { .directory {
background: var(--body_background); background: var(--body_background);
border-collapse: collapse; border-collapse: collapse;
/* backdrop-filter: blur(4px); */ backdrop-filter: blur(4px);
max-width: 1200px; max-width: 1200px;
margin: auto; /* center */ margin: auto; /* center */
} }
td { td {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
.header_row {
white-space: nowrap;
}
.node_icons {
padding: 0; padding: 0;
} }
.node { .node {
@@ -151,6 +250,12 @@ td {
display: none; display: none;
} }
.column_config {
display: flex;
flex-direction: column;
white-space: nowrap;
}
/* Large icon mode only supported on wide screens */ /* Large icon mode only supported on wide screens */
@media (min-width: 500px) { @media (min-width: 500px) {
.node_icon.large_icons { .node_icon.large_icons {

View File

@@ -0,0 +1,227 @@
<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.svelte";
import { loading_finish, loading_run, loading_start, loading_store } from "lib/Loading";
import { onMount } from "svelte";
import { breadcrumbs_store } from "wrap/BreadcrumbStore";
import { FSNavigator } from "filesystem/FSNavigator";
let {
nav,
root_path = "/me",
}: {
nav: FSNavigator
root_path?: string
} = $props();
let entries: TrashEntry[] = $state([]);
let error: string = $state("");
const load = async () => {
loading_run(async () => {
try {
entries = await fs_get_trash(root_path)
error = "";
} catch (err: any) {
error = err?.message ?? JSON.stringify(err);
}
})
};
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(() => {
breadcrumbs_store.set(breadcrumbs)
load()
});
</script>
{#snippet breadcrumbs()}
<i class="icon">delete</i>
<div>
Trash bin
{#if $loading_store === 0 && entries.length === 0}
(empty)
{:else if entries.length !== 0}
({formatDataVolume(entries.reduce(
(acc, entry) => acc + entry.node.file_size, 0
), 3)})
{/if}
</div>
<div class="separator"></div>
<button onclick={() => window.history.back()} title="Back">
<i class="icon">arrow_back</i>
</button>
<button onclick={() => nav.navigate_up()} disabled={$nav.path.length <= 1} title="Up">
<i class="icon">north</i>
</button>
<button onclick={() => nav.reload()} title="Refresh directory listing">
<i class="icon">refresh</i>
</button>
{#if entries.length !== 0}
<button class="button button_red" onclick={empty_trash}>
<i class="icon">delete_forever</i>
<span>Delete all</span>
</button>
{/if}
{/snippet}
{#if error}
<p class="error">{error}</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 at</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">
{entry.original_path ? entry.original_path.slice(0, -entry.node.name.length) : "—"}
</td>
<td class="hide_small">
{entry.deletion_date ? formatDate(entry.deletion_date) : "—"}
</td>
<td class="hide_small">
{#if entry.node.type === "file"}
{formatDataVolume(entry.node.file_size, 3)}
{/if}
</td>
<td>
<div class="row_actions">
<button title="Restore to original location" onclick={() => restore_entry(entry)}>
<i class="icon">restore_from_trash</i>
</button>
<button class="button button_red" title="Delete permanently" onclick={() => delete_entry(entry)}>
<i class="icon">delete_forever</i>
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<style>
.separator {
flex: 1 1 auto;
}
.error {
color: var(--danger_color, #c0392b);
}
.trash_table {
width: 100%;
border-collapse: collapse;
background: var(--body_background);
border-radius: 8px;
}
.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;
}
.node_icon {
display: block;
width: 2em;
height: 2em;
object-fit: contain;
}
.name_cell {
word-break: break-all;
}
.entry_link {
color: var(--body_text_color);
text-decoration: none;
}
.entry_link:hover {
text-decoration: underline;
}
.row_actions {
display: flex;
gap: 0.25em;
}
@media (max-width: 600px) {
.hide_small {
display: none;
}
}
</style>

View File

@@ -10,21 +10,23 @@
// 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.svelte"
import { get_user } from "lib/NovaAPI"
// code and an error message // code and an error message
export const upload_file = ( export const upload_file = async (
file: Blob, file: Blob,
path: string, path: string,
on_progress: (loaded: number, total: number) => void, on_progress: (loaded: number, total: number) => void,
on_success: (id: string) => void, on_success: (id: string) => void,
on_error: (code: string, message: string) => void, on_error: (code: string, message: string) => void,
) => { ) => {
const user = await get_user()
// Check the file size limit. For free accounts it's 20 GB // Check the file size limit. For free accounts it's 20 GB
if (window["user"].subscription.file_size_limit === 0) { if (user !== null && user.subscription.file_size_limit === 0) {
window["user"].subscription.file_size_limit = 20e9 user.subscription.file_size_limit = 20e9
} }
if (file.size > window["user"].subscription.file_size_limit) { if (file.size > user.subscription.file_size_limit) {
on_error( on_error(
"file_too_large", "file_too_large",
"This file is too large. Check out the Pro subscription to increase the file size limit" "This file is too large. Check out the Pro subscription to increase the file size limit"

View File

@@ -21,7 +21,7 @@ let error_message = $state("")
let xhr: XMLHttpRequest = null let xhr: XMLHttpRequest = null
export const start = () => { export const start = () => {
xhr = upload_file( upload_file(
job.file, job.file,
job.path, job.path,
(prog_loaded, prog_total) => { (prog_loaded, prog_total) => {
@@ -45,9 +45,10 @@ export const start = () => {
// We don't send the finished signal so the user can read the error // We don't send the finished signal so the user can read the error
// messsage and manually dismiss it through the cancel button // messsage and manually dismiss it through the cancel button
}, },
) ).then(result => {
xhr = result
job.status = "uploading" job.status = "uploading"
})
} }
const cancel = () => { const cancel = () => {

View File

@@ -64,7 +64,7 @@ onMount(() => {
{@render children?.()} {@render children?.()}
<div bind:this={background_div} class="background_div"> <div bind:this={background_div} class="background_div">
<TextBlock width="auto"> <TextBlock>
<audio <audio
bind:this={player} bind:this={player}
class="player" class="player"
@@ -91,16 +91,18 @@ onMount(() => {
</div> </div>
<h2>Tracklist</h2> <h2>Tracklist</h2>
{#each siblings as sibling (sibling.path)} {#each siblings as sibling (sibling.id)}
<a href={"/d"+fs_encode_path(sibling.path)} class="node" class:playing={sibling.path === $nav.base.path}> {#if !sibling.is_hidden()}
{#if sibling.path === $nav.base.path} <a href={"/d"+fs_encode_path(sibling.path)} class="node" class:playing={sibling.path === $nav.base.path}>
<i class="play_arrow icon">play_arrow</i> {#if sibling.path === $nav.base.path}
{:else} <i class="play_arrow icon">play_arrow</i>
<img src={fs_node_icon(sibling, 64, 64)} class="node_icon" alt="icon"/> {:else}
{/if} <img src={fs_node_icon(sibling, 64, 64)} class="node_icon" alt="icon"/>
<span>{sibling.name}</span> {/if}
<br/> <span>{sibling.name}</span>
</a> <br/>
</a>
{/if}
{/each} {/each}
</TextBlock> </TextBlock>
</div> </div>
@@ -111,6 +113,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

@@ -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

@@ -3,6 +3,7 @@ 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.svelte";
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 nav={nav} 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

@@ -8,15 +8,46 @@ let { nav }: {
nav: FSNavigator; nav: FSNavigator;
} = $props(); } = $props();
let viewport: HTMLDivElement = $state()
let container: HTMLDivElement = $state() let container: HTMLDivElement = $state()
let zoom = $state(false) let image: HTMLImageElement = $state()
let x = 0, y = 0
let dragging = false // Position and size of the area between the breadcrumbs bar and the toolbar,
// in viewport coordinates. The image is fitted and centered inside of this
// area, while the container it lives in covers the whole screen. That way a
// zoomed in image can be panned underneath the two bars instead of being cut
// off by them. right and bottom are the distances to the screen edges, because
// that's what the CSS needs
let area = $state({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 })
// Zoom level of the image. 1 means the image is scaled to fit the screen.
// translate_x and translate_y are the pan offset in screen pixels, relative to
// the centered position
let scale = $state(1)
let translate_x = $state(0)
let translate_y = $state(0)
// Enables a CSS transition. Only used for animated zoom steps (double click and
// reset), continuous gestures need to follow the cursor exactly
let animate = $state(false)
let animate_timeout: ReturnType<typeof setTimeout>
let zoomed = $derived(scale > 1.001)
// Currently active mouse / touch / pen contacts, by pointer id. This is
// deliberately not reactive. The swipe navigation resets its gesture whenever
// its properties change, so updating a state variable on every pointer event
// would cancel every swipe before it can trigger
let pointers = new Map<number, { x: number, y: number }>()
let pinch_distance = 0
let pinch_x = 0
let pinch_y = 0
let swipe_prev = $state(true) let swipe_prev = $state(true)
let swipe_next = $state(true) let swipe_next = $state(true)
export const update = async () => { export const update = async () => {
loading_start() loading_start()
reset_zoom(false)
// Figure out if there are previous or next files. If not then we disable // Figure out if there are previous or next files. If not then we disable
// swiping controls in that direction // swiping controls in that direction
@@ -32,92 +63,294 @@ export const update = async () => {
const on_load = () => loading_finish() const on_load = () => loading_finish()
const mousedown = (e: MouseEvent) => { const clamp = (val: number, min: number, max: number) => Math.min(Math.max(val, min), max)
if (!dragging && e.which === 1 && zoom) {
x = e.pageX
y = e.pageY
dragging = true
e.preventDefault() // Measures the area the image has to fit in. The container itself covers the
e.stopPropagation() // whole screen, so its size can't be used for this
return false const measure_area = () => {
if (!viewport) {
return
}
const rect = viewport.getBoundingClientRect()
area = {
top: rect.top,
left: rect.left,
right: Math.max(0, document.documentElement.clientWidth - rect.right),
bottom: Math.max(0, document.documentElement.clientHeight - rect.bottom),
width: rect.width,
height: rect.height,
}
clamp_pan()
}
// The area changes when the window is resized, but also when the toolbar is
// collapsed or the navigation menu is opened
$effect(() => {
const observer = new ResizeObserver(measure_area)
observer.observe(viewport)
return () => observer.disconnect()
})
// The maximum zoom level. Zooming in further than the image's native
// resolution is pointless, but we always allow at least 8x so small images can
// be inspected too
const max_scale = () => {
if (!image || image.offsetWidth === 0) {
return 8
}
return clamp(image.naturalWidth / image.offsetWidth, 8, 40)
}
// Limits how far the image can be panned. Every edge of the image can be
// brought to the matching edge of the area, and no further, so that no part of
// the image can be pushed into a place where it can't be seen. When the scaled
// image is smaller than the area in an axis it stays centered on that axis.
//
// The limits are relative to the area and not to the container, even though
// the container is what clips the image. The container covers the whole
// screen, but the parts of it behind the toolbars and the navigation menu are
// covered up, so an edge parked there would be invisible
const clamp_pan = () => {
if (!image) {
return
}
const max_x = Math.max(0, (image.offsetWidth * scale - area.width) / 2)
const max_y = Math.max(0, (image.offsetHeight * scale - area.height) / 2)
translate_x = clamp(translate_x, -max_x, max_x)
translate_y = clamp(translate_y, -max_y, max_y)
}
// Zooms to new_scale while keeping the image pixel which is under the given
// screen coordinate in place
const zoom_at = (client_x: number, client_y: number, new_scale: number) => {
if (!container) {
return
}
const clamped = clamp(new_scale, 1, max_scale())
const factor = clamped / scale
// Cursor position relative to the center of the fitted image, which is also
// its transform origin
const off_x = client_x - (area.left + area.width / 2)
const off_y = client_y - (area.top + area.height / 2)
translate_x = off_x * (1 - factor) + translate_x * factor
translate_y = off_y * (1 - factor) + translate_y * factor
scale = clamped
clamp_pan()
}
const reset_zoom = (smooth: boolean) => {
if (smooth) {
start_animation()
}
scale = 1
translate_x = 0
translate_y = 0
}
const start_animation = () => {
animate = true
clearTimeout(animate_timeout)
animate_timeout = setTimeout(() => animate = false, 200)
}
const wheel = (e: WheelEvent) => {
e.preventDefault()
let delta = e.deltaY
if (e.deltaMode === 1) {
delta *= 16 // Delta is in lines instead of pixels
} else if (e.deltaMode === 2) {
delta *= container.clientHeight // Delta is in pages
}
// Trackpad pinch gestures are reported as a wheel event with the ctrl key
// held down. Those deltas are much smaller, so they need more amplification
const speed = e.ctrlKey ? 0.01 : 0.001
zoom_at(e.clientX, e.clientY, scale * Math.exp(-delta * speed))
}
const update_pinch = () => {
const [a, b] = [...pointers.values()]
pinch_distance = Math.hypot(a.x - b.x, a.y - b.y)
pinch_x = (a.x + b.x) / 2
pinch_y = (a.y + b.y) / 2
}
const pointerdown = (e: PointerEvent) => {
if (e.pointerType === "mouse" && e.button !== 0) {
return
}
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
container.setPointerCapture(e.pointerId)
if (pointers.size === 2) {
update_pinch()
} }
} }
const mousemove = (e: MouseEvent) => {
if (dragging) {
container.scrollLeft = container.scrollLeft - (e.pageX - x)
container.scrollTop = container.scrollTop - (e.pageY - y)
x = e.pageX const pointermove = (e: PointerEvent) => {
y = e.pageY const previous = pointers.get(e.pointerId)
if (previous === undefined) {
return
}
e.preventDefault() const current = { x: e.clientX, y: e.clientY }
e.stopPropagation() pointers.set(e.pointerId, current)
return false
if (pointers.size === 1) {
// Panning only does something when the image is zoomed in. Otherwise we
// leave the gesture alone so the swipe navigation can pick it up
if (!zoomed) {
return
}
translate_x += current.x - previous.x
translate_y += current.y - previous.y
clamp_pan()
} else if (pointers.size === 2) {
const [a, b] = [...pointers.values()]
const distance = Math.hypot(a.x - b.x, a.y - b.y)
const center_x = (a.x + b.x) / 2
const center_y = (a.y + b.y) / 2
if (pinch_distance > 0) {
// Follow the movement of the fingers, then zoom around their center
translate_x += center_x - pinch_x
translate_y += center_y - pinch_y
zoom_at(center_x, center_y, scale * distance / pinch_distance)
}
pinch_distance = distance
pinch_x = center_x
pinch_y = center_y
}
e.preventDefault()
}
const pointerup = (e: PointerEvent) => {
if (!pointers.delete(e.pointerId)) {
return
}
if (pointers.size === 2) {
update_pinch()
} else {
pinch_distance = 0
} }
} }
const mouseup = (e: MouseEvent) => {
if (dragging) {
dragging = false
e.preventDefault() const dblclick = (e: MouseEvent) => {
e.stopPropagation() start_animation()
return false
if (zoomed) {
reset_zoom(false)
} else {
zoom_at(e.clientX, e.clientY, 3)
} }
} }
</script> </script>
<svelte:window onmousemove={mousemove} onmouseup={mouseup} /> <svelte:window onresize={measure_area} />
<!-- This element is only here to measure the space between the breadcrumbs bar
and the toolbar. It has no contents of its own, the container is taken out of
the flow -->
<div class="viewport" bind:this={viewport}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
bind:this={container} bind:this={container}
class="container" class="container"
class:zoom class:zoomed
onwheel={wheel}
onpointerdown={pointerdown}
onpointermove={pointermove}
onpointerup={pointerup}
onpointercancel={pointerup}
ondblclick={dblclick}
use:swipe_nav={{ use:swipe_nav={{
enabled: !zoom, enabled: !zoomed,
prev: swipe_prev, prev: swipe_prev,
next: swipe_next, next: swipe_next,
on_prev: () => nav.open_sibling(-1), on_prev: () => nav.open_sibling(-1),
on_next: () => nav.open_sibling(1), on_next: () => nav.open_sibling(1),
}} }}
> >
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<img <img
ondblclick={() => {zoom = !zoom}} bind:this={image}
onmousedown={mousedown}
onload={on_load} onload={on_load}
onerror={on_load} onerror={on_load}
class="image" class="image"
class:zoom class:animate
style="
top: {area.top}px;
right: {area.right}px;
bottom: {area.bottom}px;
left: {area.left}px;
max-width: {area.width}px;
max-height: {area.height}px;
transform: translate({translate_x}px, {translate_y}px) scale({scale});
"
src={fs_path_url($nav.base.path)} src={fs_path_url($nav.base.path)}
alt="no description available" alt="no description available"
draggable="false"
/> />
</div> </div>
</div>
<style> <style>
.container { .viewport {
display: flex;
justify-content: center;
height: 100%; height: 100%;
width: 100%; width: 100%;
}
.container {
/* Fixed positioning puts the container on top of the whole screen, and
* escapes the overflow of the preview area, so that a zoomed in image can
* slide underneath the breadcrumbs bar and the toolbar. Both of those are
* painted over it: the breadcrumbs bar has a z-index, and the toolbar comes
* after the preview area in the document */
position: fixed;
inset: 0;
overflow: hidden; overflow: hidden;
/* Disables the browser's own scroll and zoom gestures, we handle those
* ourselves */
touch-action: none;
} }
.container.zoom { .container.zoomed {
overflow: auto;
justify-content: unset;
}
.image {
position: relative;
display: block;
margin: auto;
max-width: 100%;
max-height: 100%;
cursor: pointer;
box-shadow: 1px 1px 4px 0px var(--shadow_color);
}
.image.zoom {
max-width: none;
max-height: none;
cursor: move; cursor: move;
} }
.image {
/* The image is positioned absolutely so that its size does not affect the
* height of the viewport element. If it did then a tall image would stretch
* the page beyond the bottom of the screen, because a percentage max-height
* cannot be resolved against a parent which is sized by its contents.
*
* The insets and the maximum size are set from the measured area, in
* pixels. They can't be percentages, because those would resolve against
* the container, which covers the entire screen. Padding on the container
* would not work either, absolutely positioned elements ignore it. The
* automatic margins center the image within the insets */
position: absolute;
display: block;
margin: auto;
box-shadow: 1px 1px 4px 0px var(--shadow_color);
transform-origin: center center;
will-change: transform;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
}
.image.animate {
transition: transform 200ms ease-out;
}
</style> </style>

View File

@@ -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,31 +1,30 @@
<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 Pick one of the subscription plans above to upgrade your
form below to activate prepaid on this account. account to premium.
</p> </p>
<Checkout/>
{/if} {/if}
</section> </section>
</div> </div>

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,69 +28,23 @@ 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"> <h2>Features</h2>
<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> <ul>
<li> <li>
Serving large files to millions of people worldwide Cloud storage with <a href="/filesystem#toc_7">rclone</a> and
FTPS support
</li> </li>
<li> <li>
Storing files for less money than all the competition Customizable download pages (custom branding colours, header and
background images)
</li>
<li>
Fault tolerance for up to four storage server failures
</li>
<li>
Caching servers highly optimized for long-distance data transfer
</li> </li>
</ul> </ul>
<Pricing/>
</section>
</div>
<header>
<h1>Features</h1>
</header>
<div class="page_content">
<section>
<ul>
<li>
<span class="bold">Unlimited</span> storage space
</li>
<li>
Cloud storage with <a href="/filesystem#toc_7">rclone</a> and <a
href="/filesystem#toc_10">FTPS</a> support
</li>
<li>
<span class="bold">Customizable</span> download pages (custom
branding colours, header and background images)
</li>
<li>
<span class="bold">Fault tolerance</span> for up to four storage
server failures
</li>
<li>
<span class="bold">1.8 Terabits per second</span> of total bandwidth
capacity
</li>
<li>
Twenty-three high-performance caching servers optimized for data
transfer over long distances
</li>
<li>
File caching in
<span class="bold">Portland</span>,
<span class="bold">Querétaro</span>,
<span class="bold">New York</span>,
<span class="bold">São Paulo</span>,
<span class="bold">Frankfurt</span> and
<span class="bold">Singapore</span>
</li>
</ul>
<img class="image" src="/res/img/map.webp" alt="A globe showing datacenter locations"/>
<h3>What you don't get</h3> <h3>What you don't get</h3>
<ul> <ul>
<li> <li>
@@ -98,15 +52,17 @@ let upload_widget
in case of emergency in case of emergency
</li> </li>
<li> <li>
Commercial support - If you have a question, please check the <a Commercial support - I'm busy, please don't bother me
href="/about">Q&A page</a> first. If that doesn't answer it, try
the <a href="https://discord.gg/UDjaBGwr4p"
target="_blank">support channel</a> on Discord.
</li> </li>
</ul> </ul>
</section> </section>
</div> </div>
<header>
<h1>Subscription plans</h1>
</header>
<SubscriptionPlans/>
<header> <header>
<h1>Get started</h1> <h1>Get started</h1>
</header> </header>
@@ -157,43 +113,8 @@ header > h1 {
background-size: contain; background-size: contain;
background-position: center; background-position: center;
} }
.image {
max-width: 100%;
border-radius: 12px;
}
.bold { .bold {
font-weight: bold; font-weight: bold;
color: var(--highlight_color); color: var(--highlight_color);
} }
.prices {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.prices > div {
flex: 1 0 200px;
min-width: 200px;
display: flex;
flex-direction: row;
text-align: center;
border-radius: 6px;
overflow: hidden;
border: 2px solid var(--card_color);
}
.prices > div > div {
flex: 1 1 auto;
padding: 4px;
font-size: 1.3em;
}
.prices > div > div:nth-child(2) {
background: var(--card_color);
}
@media(max-width: 1000px) {
.prices > div {
flex-direction: column;
}
}
</style> </style>

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,229 @@
<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>
<span class="title">{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;
}
.title {
font-size: 1.5em;
}
.plan > .order {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.plan > .order > .button {
flex-direction: column;
}
.bold {
font-weight: bold;
}
</style>

View File

@@ -6,20 +6,24 @@ let {
style = "", style = "",
large_icon = false, large_icon = false,
small_icon = false, small_icon = false,
children disabled = false,
children,
onclick,
}: { }: {
text?: string; text?: string;
style?: string; style?: string;
large_icon?: boolean; large_icon?: boolean;
small_icon?: boolean; small_icon?: boolean;
disabled?: boolean;
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
onclick?: (e: MouseEvent) => void;
} = $props(); } = $props();
let failed = $state(false) let failed = $state(false)
let success = $state(false) let success = $state(false)
// Exported so it can be called manually // Exported so it can be called manually
export const copy = () => { export const copy = (e: MouseEvent) => {
try { try {
copy_text(text) copy_text(text)
success = true success = true
@@ -32,6 +36,10 @@ export const copy = () => {
success = false success = false
failed = false failed = false
}, 10000) }, 10000)
if (onclick !== undefined) {
onclick(e)
}
} }
</script> </script>
@@ -44,7 +52,7 @@ export const copy = () => {
class:large_icon class:large_icon
class:small_icon class:small_icon
title="Copy text to clipboard" title="Copy text to clipboard"
disabled={text === ""} disabled={disabled}
> >
<i class="icon">content_copy</i> <i class="icon">content_copy</i>
<span> <span>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
let { children }: { let { children, onclose }: {
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
onclose?: () => void
} = $props(); } = $props();
let dialog: HTMLDialogElement = $state() let dialog: HTMLDialogElement = $state()
@@ -8,12 +9,12 @@ export const open = (origin: DOMRect|MouseEvent) => {
// Show the window so we can get the location // Show the window so we can get the location
dialog.showModal() dialog.showModal()
const edge_offset = 2 const edge_offset = 10
// Get the egdes of the screen, so the window does not spawn off-screen // Get the egdes of the screen, so the window does not spawn off-screen
const dialog_rect = dialog.getBoundingClientRect() const dialog_rect = dialog.getBoundingClientRect()
const max_left = window.innerWidth - dialog_rect.width - edge_offset const max_left = document.documentElement.clientWidth - dialog_rect.width - edge_offset
const max_top = window.innerHeight - dialog_rect.height - edge_offset const max_top = document.documentElement.clientHeight - dialog_rect.height - edge_offset
let min_left: number, min_top: number let min_left: number, min_top: number
if (origin instanceof DOMRect) { if (origin instanceof DOMRect) {
@@ -35,6 +36,9 @@ export const open = (origin: DOMRect|MouseEvent) => {
export const close = () => { export const close = () => {
dialog.close() dialog.close()
if (onclose !== undefined) {
onclose()
}
} }
// Attach a click handler so that the dialog is closed when the user clicks // Attach a click handler so that the dialog is closed when the user clicks
@@ -44,7 +48,7 @@ export const close = () => {
const click = (e: MouseEvent) => { const click = (e: MouseEvent) => {
if (e.target === dialog) { if (e.target === dialog) {
e.preventDefault() e.preventDefault()
dialog.close() close()
} }
} }
</script> </script>

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

@@ -4,7 +4,7 @@ let {
active_field = "", active_field = "",
asc = true, asc = true,
sort_func, sort_func,
children children,
}: { }: {
field?: string; field?: string;
active_field?: string; active_field?: string;
@@ -14,7 +14,7 @@ let {
} = $props(); } = $props();
</script> </script>
<button onclick={() => sort_func(field)}> <button onclick={() => sort_func(field)} class="button flat">
{#if active_field === field} {#if active_field === field}
{#if asc}{:else}{/if} {#if asc}{:else}{/if}
{/if} {/if}
@@ -25,8 +25,6 @@ let {
button { button {
display: block; display: block;
margin: 0; margin: 0;
line-height: 1em;
width: 100%; width: 100%;
text-align: initial;
} }
</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 { countries } from "country-data-list" import { countries } from "country-data-list"
import { check_response, get_endpoint } from "./NovaAPI" import { check_response, get_endpoint, type GenericResponse } from "./NovaAPI"
export const country_name = (country: string) => { export const country_name = (country: string) => {
if (country !== "" && countries[country] !== undefined) { if (country !== "" && countries[country] !== undefined) {
@@ -27,3 +27,15 @@ export const get_admin_invoices = async (year: number, month: number) => {
) )
) as Invoice[] ) as Invoice[]
}; };
export const admin_decommission_node = async (peer_id: string) => {
let form = new FormData()
form.set("peer_id", peer_id)
return await check_response(
await fetch(
get_endpoint() + "/admin/decommission_node",
{ method: "POST", body: form }
)
) as GenericResponse
};

View File

@@ -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

@@ -1,6 +1,8 @@
// Response types // Response types
// ============== // ==============
import { get_endpoint } from "./NovaAPI"
export type GenericResponse = { export type GenericResponse = {
value: string, value: string,
message: string, message: string,
@@ -32,6 +34,8 @@ export class FSNode {
mode_string: string mode_string: string
mode_octal: string mode_octal: string
created_by: string created_by: string
recursive_size: number
recursive_children: number
abuse_type?: string abuse_type?: string
abuse_report_time?: string abuse_report_time?: string
@@ -103,7 +107,8 @@ export type FSPermissions = {
} }
export type FSContext = { export type FSContext = {
premium_transfer: boolean, premium_transfer: boolean
can_share: boolean
} }
// API parameters // API parameters
@@ -185,7 +190,7 @@ export const fs_get_node = async (path: string) => {
// - shared, boolean. If true the node will receive a public ID // - shared, boolean. If true the node will receive a public ID
// //
// Returns the modified filesystem node object // Returns the modified filesystem node object
export const fs_update = async (path: string, opts: NodeOptions) => { export const fs_update = async (path: string, opts: NodeOptions): Promise<FSNode> => {
const form = new FormData() const form = new FormData()
form.append("action", "update") form.append("action", "update")
@@ -311,6 +316,26 @@ export const fs_check_response = async (resp: Response) => {
return JSON.parse(text) return JSON.parse(text)
} }
export const fs_check_is_error = (err: any, target: string) => {
if (typeof err.value !== "string") {
return false
}
if (err.value === target) {
return true
}
if (err.value === "multiple_errors" && Array.isArray(err.errors)) {
for (const embedded_err of err.errors) {
if (embedded_err.value === target) {
return true
}
}
}
return false
}
export const fs_path_url = (path: string) => { export const fs_path_url = (path: string) => {
if (!path || path.length === 0) { if (!path || path.length === 0) {
return "" return ""
@@ -319,11 +344,7 @@ export const fs_path_url = (path: string) => {
path = "/" + path path = "/" + path
} }
if (window["api_endpoint"] !== undefined) { return get_endpoint() + "/filesystem" + fs_encode_path(path)
return window["api_endpoint"] + "/filesystem" + fs_encode_path(path)
} else {
throw Error("fs_path_url: api_endpoint is undefined")
}
} }
export const fs_encode_path = (path: string) => { export const fs_encode_path = (path: string) => {

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,
@@ -9,6 +11,7 @@ export type GenericResponse = {
} }
export type User = { export type User = {
id: string,
username: string, username: string,
email: string, email: string,
otp_enabled: boolean, otp_enabled: boolean,
@@ -18,12 +21,10 @@ export type User = {
filesystem_node_count: number, filesystem_node_count: number,
is_admin: boolean, is_admin: boolean,
balance_micro_eur: number, balance_micro_eur: number,
hotlinking_enabled: boolean,
monthly_transfer_cap: number, monthly_transfer_cap: number,
monthly_transfer_used: number, monthly_transfer_used: number,
file_viewer_branding: Map<string, string>, file_viewer_branding: Map<string, string>,
file_embed_domains: string, embed_domains: string,
skip_file_viewer: boolean,
affiliate_user_name: string, affiliate_user_name: string,
checkout_country: string, checkout_country: string,
checkout_name: string, checkout_name: string,
@@ -35,14 +36,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 +105,68 @@ 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)) { user.update((user: User) => {
((window as any).user as User)[key] = data[key] for (let key of Object.keys(data)) {
} user[key] = data[key]
}
return 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

@@ -16,17 +16,31 @@ export const swipe_nav = (
let start_x = 0 let start_x = 0
let start_y = 0 let start_y = 0
let render_offset = 0 let render_offset = 0
// Set when a second finger touches the screen. Multi-touch gestures are
// zoom gestures, not swipes, so we ignore the rest of the gesture
let multi_touch = false
let enabled = props.enabled === undefined ? true : props.enabled let enabled = props.enabled === undefined ? true : props.enabled
let prev = props.prev === undefined ? true : props.prev let prev = props.prev === undefined ? true : props.prev
let next = props.next === undefined ? true : props.next let next = props.next === undefined ? true : props.next
const touchstart = (e: TouchEvent) => { const touchstart = (e: TouchEvent) => {
if (e.touches.length > 1) {
multi_touch = true
set_offset(0, true)
return
}
multi_touch = false
start_x = e.touches[0].clientX start_x = e.touches[0].clientX
start_y = e.touches[0].clientY start_y = e.touches[0].clientY
render_offset = 0 render_offset = 0
} }
const touchmove = (e: TouchEvent) => { const touchmove = (e: TouchEvent) => {
if (multi_touch) {
return
}
const offset_x = e.touches[0].clientX - start_x const offset_x = e.touches[0].clientX - start_x
if (!enabled || (offset_x < 0 && !next) || (offset_x > 0 && !prev)) { if (!enabled || (offset_x < 0 && !next) || (offset_x > 0 && !prev)) {
return return
@@ -45,8 +59,8 @@ export const swipe_nav = (
} }
} }
const touchend = (e: TouchEvent) => { const touchend = (_: TouchEvent) => {
if (!enabled) { if (!enabled || multi_touch) {
return return
} }

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

@@ -1,157 +0,0 @@
<script>
import { preventDefault } from 'svelte/legacy';
import { onMount } from "svelte";
import Pro from "icons/Pro.svelte";
import { formatDataVolume } from "util/Formatting";
import ProgressBar from "util/ProgressBar.svelte";
import SuccessMessage from "util/SuccessMessage.svelte";
import { loading_finish, loading_start } from "lib/Loading";
let success_message = $state()
let hotlinking = $state(window.user.hotlinking_enabled)
let transfer_cap = $state(window.user.monthly_transfer_cap / 1e12)
let skip_viewer = $state(window.user.skip_file_viewer)
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()
try {
const resp = await fetch(
window.api_endpoint+"/user",
{ method: "PUT", body: form },
)
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")
} catch (err) {
success_message.set(false, "Failed to update subscription: "+err)
} finally {
loading_finish()
}
}
let toggle_hotlinking = () => {
hotlinking = !hotlinking
update()
}
let transfer_used = $state(0)
let load_transfer_used = () => {
let today = new Date()
let start = new Date()
start.setDate(start.getDate() - 30)
fetch(
window.api_endpoint + "/user/time_series/egress" +
"?start=" + start.toISOString() +
"&end=" + today.toISOString() +
"&interval=60"
).then(resp => {
if (!resp.ok) { return Promise.reject("Error: " + resp.status); }
return resp.json();
}).then(resp => {
transfer_used = resp.amounts.reduce((acc, cur) => { return acc + cur }, 0)
}).catch(e => {
console.error("Error requesting time series: " + e);
})
}
let toggle_skip_viewer = () => {
skip_viewer = !skip_viewer
update()
}
onMount(() => {
load_transfer_used()
})
</script>
<section>
<h2><Pro/>Hotlinking (bandwidth sharing)</h2>
<SuccessMessage bind:this={success_message}></SuccessMessage>
<button onclick={toggle_hotlinking}>
{#if hotlinking}
<i class="icon green">check</i> ON (click to turn off)
{:else}
<i class="icon red">close</i> OFF (click to turn on)
{/if}
</button>
<p>
When hotlinking is enabled all the bandwidth that your files use will be
subtracted from your data cap. Advertisements will be disabled on the
download pages for your files and download speed will be unlimited. The
rate limiting captcha for files is also disabled when hotlinking is on.
You can directly embed your file's download link anywhere, you don't
need to use the file viewer page.
</p>
<h2><Pro/>Bill shock limit</h2>
<p>
Billshock limit in terabytes per month (1 TB = 1000 GB). Set to 0 to
disable.
</p>
<form onsubmit={preventDefault(update)} class="billshock_container">
<input type="number" bind:value={transfer_cap} step="0.1" min="0" style="width: 5em;"/>
<div style="margin: 0.5em;">TB</div>
<button type="submit">
<i class="icon">save</i> Save
</button>
</form>
<p>
Bandwidth used in the last 30 days: {formatDataVolume(transfer_used, 3)},
new limit: {formatDataVolume(transfer_cap*1e12, 3)}
</p>
<ProgressBar used={transfer_used} total={transfer_cap*1e12}></ProgressBar>
<p>
The billshock limit limits how much bandwidth your account can use in a
30 day window. When this limit is reached hotlinking will be disabled
and you will no longer be charged for bandwidth usage. This is mostly
useful for the Prepaid subscription, but it works for Patreon plans too.
Set to 0 to disable the limit.
</p>
<h2><Pro/>Skip download page</h2>
<button onclick={toggle_skip_viewer}>
{#if skip_viewer}
<i class="icon green">check</i> ON (click to turn off)
{:else}
<i class="icon red">close</i> OFF (click to turn on)
{/if}
</button>
<p>
This setting only applies to your account, others will still see the
download page when visiting your files. When this is enabled you will be
redirected to the file download API when clicking a Nova link.
This means that images, videos and PDF files will be opened directly in
your browser, and other files are immediately downloaded to your device.
This only works for single file links, not albums. You will need a Pro
subscription to use this feature.
</p>
</section>
<style>
.green {
color: var(--highlight_color);
}
.red {
color: var(--danger_color);
}
.billshock_container {
display: flex;
flex-direction: row;
align-items: center;
}
</style>

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,86 +0,0 @@
<script>
import { preventDefault } from 'svelte/legacy';
import { onMount } from "svelte";
import Persistence from "icons/Persistence.svelte";
import SuccessMessage from "util/SuccessMessage.svelte";
import { loading_finish, loading_start } from "lib/Loading";
let success_message = $state()
// Embedding settings
let embed_domains = $state("")
const save_embed = async () => {
const form = new FormData()
form.append("embed_domains", embed_domains)
loading_start()
try {
const resp = await fetch(
window.api_endpoint+"/user",
{ method: "PUT", body: form }
);
if(resp.status >= 400) {
let json = await resp.json()
console.debug(json)
throw json.message
}
success_message.set(true, "Changes saved")
} catch(err) {
success_message.set(false, err)
} finally {
loading_finish()
}
}
onMount(() => {
embed_domains = window.user.file_embed_domains
})
</script>
<section>
<h2><Persistence/>Embedding controls</h2>
<SuccessMessage bind:this={success_message}></SuccessMessage>
{#if !window.user.subscription.file_viewer_branding}
<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">
To use embedding restrictions hotlinking needs to be enabled.
Enable hotlinking on the
<a href="/user/sharing/bandwidth">sharing settings page</a>.
</div>
{/if}
<p>
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
one of your files the request will be blocked.
</p>
<p>
The list should be formatted as a list of domain names separated by a
space. Like this: 'nova.storage google.com twitter.com'
</p>
Domain names:<br/>
<form class="form_row" onsubmit={preventDefault(save_embed)}>
<input class="grow" bind:value={embed_domains} type="text"/>
<button class="shrink" action="submit"><i class="icon">save</i> Save</button>
</form>
</section>
<style>
.form_row {
display: inline-flex;
flex-direction: row;
width: 100%;
align-items: center;
}
.grow {
flex: 1 1 auto;
}
.shrink {
flex: 0 0 auto;
}
</style>

View File

@@ -34,10 +34,10 @@ 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">increase or disable the limit</a> to
continue sharing files. continue sharing files.
</p> </p>
{/if} {/if}
@@ -56,10 +56,10 @@ 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">increase or disable the limit</a> to
continue sharing files. continue sharing files.
</p> </p>
{/if} {/if}

View File

@@ -7,12 +7,11 @@ import ConnectApp from "./ConnectApp.svelte";
import ActivityLog from "./ActivityLog.svelte"; import ActivityLog from "./ActivityLog.svelte";
import DepositCredit from "./DepositCredit.svelte"; import DepositCredit from "./DepositCredit.svelte";
import TabMenu, { type Tab } from "util/TabMenu.svelte"; import TabMenu, { type Tab } from "util/TabMenu.svelte";
import BandwidthSharing from "./BandwidthSharing.svelte";
import EmbeddingControls from "./EmbeddingControls.svelte";
import Dashboard from "./dashboard/Dashboard.svelte"; import Dashboard from "./dashboard/Dashboard.svelte";
import AffiliatePrompt from "./AffiliatePrompt.svelte"; import AffiliatePrompt from "./AffiliatePrompt.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { get_user, type User } from "lib/NovaAPI"; import { get_user, type User } from "lib/NovaAPI";
import SharingSettings from "./SharingSettings.svelte";
let pages: Tab[] = [ let pages: Tab[] = [
{ {
@@ -50,21 +49,9 @@ let pages: Tab[] = [
], ],
}, { }, {
path: "/user/sharing", path: "/user/sharing",
title: "Sharing", title: "Sharing settings",
icon: "share", icon: "share",
subpages: [ component: SharingSettings,
{
path: "/user/sharing/bandwidth",
title: "Sharing settings",
icon: "share",
component: BandwidthSharing,
}, {
path: "/user/sharing/embedding",
title: "Embedding Controls",
icon: "code",
component: EmbeddingControls,
},
],
}, { }, {
path: "/user/connect_app", path: "/user/connect_app",
title: "Apps", title: "Apps",

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { onMount } from "svelte";
import { formatDataVolume } from "util/Formatting";
import ProgressBar from "util/ProgressBar.svelte";
import { loading_run } from "lib/Loading";
import { get_endpoint, put_user } from 'lib/NovaAPI';
import SuccessMessage from 'util/SuccessMessage.svelte';
import { user } from "lib/UserStore";
let success_message: SuccessMessage = $state()
let transfer_cap = $state($user === null ? 0 : $user.monthly_transfer_cap / 1e12)
const update_user = async (e: SubmitEvent) => {
if (e !== null) {
e.preventDefault()
}
loading_run(async () => {
try {
await put_user({
transfer_cap: transfer_cap*1e12,
embed_domains: embed_domains.join(" "),
})
success_message.set(true, "Sharing settings updated")
} catch (err) {
success_message.set(false, "Failed to update user: "+err)
}
})
}
let transfer_used = $state(0)
let load_transfer_used = () => {
let today = new Date()
let start = new Date()
start.setDate(start.getDate() - 30)
fetch(
get_endpoint() + "/user/time_series/egress" +
"?start=" + start.toISOString() +
"&end=" + today.toISOString() +
"&interval=60"
).then(resp => {
if (!resp.ok) { return Promise.reject("Error: " + resp.status); }
return resp.json();
}).then(resp => {
transfer_used = resp.amounts.reduce((acc, cur) => { return acc + cur }, 0)
}).catch(e => {
console.error("Error requesting time series: " + e);
})
}
// Embedding settings
let embed_domains: string[] = $state($user === null ? [] : $user.embed_domains.split(" "))
let new_domain: string = $state("")
const add_domain = (e: SubmitEvent) => {
e.preventDefault()
console.debug("Adding domain", new_domain)
embed_domains.push(new_domain)
embed_domains = embed_domains
new_domain = ""
update_user(e)
}
const del_domain = (index: number) => {
embed_domains.splice(index, 1)
embed_domains = embed_domains
update_user(null)
}
onMount(async () => {
load_transfer_used()
})
</script>
<section>
<SuccessMessage bind:this={success_message}/>
<h2>Bill shock limit</h2>
<p>
Billshock limit in terabytes per month (1 TB = 1000 GB). Set to 0 to
disable.
</p>
<form onsubmit={update_user} class="billshock_container">
<input type="number" bind:value={transfer_cap} step="0.1" min="0" style="width: 5em;"/>
<div style="margin: 0.5em;">TB</div>
<button type="submit">
<i class="icon">save</i> Save
</button>
</form>
<p>
Bandwidth used in the last 30 days: {formatDataVolume(transfer_used, 3)},
new limit: {formatDataVolume(transfer_cap*1e12, 3)}
</p>
<ProgressBar used={transfer_used} total={transfer_cap*1e12}></ProgressBar>
<p>
The billshock limit limits how much bandwidth your account can use in a
30 day window. When this limit is reached hotlinking will be disabled
and you will no longer be charged for bandwidth usage. This is mostly
useful for the Prepaid subscription, but it works for Patreon plans too.
Set to 0 to disable the limit.
</p>
<h2>Embedding controls</h2>
<p>
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
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>
<div class="highlight_border">
<form onsubmit={add_domain}>
<div class="form_row">
<span>Add domain name</span>
<input class="grow" bind:value={new_domain} type="text"/>
<button class="shrink" type="submit"><i class="icon">add</i> Add</button>
</div>
</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" onclick={() => del_domain(index)}>
<i class="icon">delete</i>
</button>
<div>{domain}</div>
</div>
{/each}
</div>
</section>
<style>
.billshock_container {
display: flex;
flex-direction: row;
align-items: center;
}
.highlight_border {
text-align: initial;
}
.form_row {
display: inline-flex;
flex-direction: row;
width: 100%;
align-items: center;
gap: 0.5em;
}
.grow {
flex: 1 1 auto;
}
.shrink {
flex: 0 0 auto;
}
</style>

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,7 @@ 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 success_message: SuccessMessage = $state() let success_message: SuccessMessage = $state()
const update = async (plan: string) => { const update = async (plan: string) => {
@@ -71,13 +70,6 @@ const update = async (plan: string) => {
<p> <p>
Here you can switch between different subscription plans. Here you can switch between different subscription plans.
</p> </p>
<p>
The Patreon subscription is managed by Patreon. Nova cannot modify
or end your subsciption. If you would like to cancel your Patreon plan
you can do that <a
href="https://www.patreon.com/settings/memberships/nova"
target="_blank">on Patreon</a>.
</p>
<p> <p>
The Prepaid subscription is charged daily based on usage. When you reach The Prepaid subscription is charged daily based on usage. When you reach
negative balance the subscription will automatically end. You need at negative balance the subscription will automatically end. You need at
@@ -91,41 +83,6 @@ const update = async (plan: string) => {
<SuccessMessage bind:this={success_message}/> <SuccessMessage bind:this={success_message}/>
<div class="feat_table"> <div class="feat_table">
<div>
<div class="feat_label" class:feat_highlight={subscription_type === "patreon"}>
Patreon<br/>
{#if subscription_type === "patreon"}
Currently active<br/>
<a class="button" href="https://www.patreon.com/settings/memberships/nova" target="_blank">
<i class="icon">settings</i>
Manage
</a>
{:else}
<a class="button" href="/api/patreon_auth/start">
<i class="icon">add_link</i>
Link Patreon
</a>
{/if}
</div>
<div class="feat_normal round_tr" class:feat_highlight={subscription_type === "patreon"}>
<p>
This subscription is managed by Patreon. You will need to <a
href="https://www.patreon.com/nova/membership"
target="_blank">purchase a plan on Patreon</a> before you
can activate this subscription. After your purchase you can
click the "Link Patreon" button and your account will be
upgraded.
</p>
<ul>
<li>€4 per month</li>
<li>No storage limit for file sharing</li>
<li>4 TB transfer limit (higher plans available)</li>
<li>Access to the <a href="/filesystem">filesystem</a></li>
<li>2 TB filesytem storage limit (higher plans available)</li>
</ul>
</div>
</div>
<div> <div>
<div class="feat_label" class:feat_highlight={subscription === "prepaid"}> <div class="feat_label" class:feat_highlight={subscription === "prepaid"}>
Prepaid<br/> Prepaid<br/>
@@ -141,11 +98,7 @@ const update = async (plan: string) => {
<div class="feat_normal round_tr" class:feat_highlight={subscription === "prepaid"}> <div class="feat_normal round_tr" class:feat_highlight={subscription === "prepaid"}>
<p> <p>
You will need a positive account balance to activate this You will need a positive account balance to activate this
plan. If you currently have a Patreon subscription active, plan. The plan deactivates when your credit runs out.
then enabling prepaid will not cancel that subscription. You
can end your subscription <a
href="https://www.patreon.com/settings/memberships/nova"
target="_blank">on Patreon.com</a>.
</p> </p>
<ul> <ul>
<li> <li>
@@ -157,12 +110,12 @@ const update = async (plan: string) => {
</li> </li>
<li> <li>
€1 per TB for data transfer (with <a €1 per TB for data transfer (with <a
href="/user/sharing/bandwidth">hotlinking</a> href="/user/sharing">hotlinking</a>
enabled) enabled)
</li> </li>
<li> <li>
File <a href="/user/sharing/embedding">embedding File <a href="/user/sharing">embedding control</a>
control</a> options options
</li> </li>
</ul> </ul>
</div> </div>
@@ -177,8 +130,8 @@ const update = async (plan: string) => {
</div> </div>
<div class="feat_normal round_br" class:feat_highlight={subscription === ""}> <div class="feat_normal round_br" class:feat_highlight={subscription === ""}>
<ul> <ul>
<li>Standard free plan, files expire after 120 days.</li> <li>Storage limit: 50 GB</li>
<li>Download limit of 6 GB per day</li> <li>Download limit: 50 GB per 30 days (sliding window)</li>
</ul> </ul>
</div> </div>
</div> </div>

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()
}) })
@@ -58,6 +58,6 @@ onMount(async () => {
<br/><br/> <br/><br/>
Egress used (30 days): Egress used (30 days):
(<a href="/user/sharing/bandwidth">set custom limit</a>) (<a href="/user/sharing">set custom limit</a>)
<HotlinkProgressBar used={transfer_used} total={transfer_cap}></HotlinkProgressBar> <HotlinkProgressBar used={transfer_used} total={transfer_cap}></HotlinkProgressBar>
{/if} {/if}

View File

@@ -32,7 +32,7 @@ const min_card_size = 0
const def_card_size = 1 const def_card_size = 1
const max_card_size = 3 const max_card_size = 3
const expand = i => { const expand = (i: number) => {
if (cards[i].size === undefined) { if (cards[i].size === undefined) {
cards[i].size = def_card_size cards[i].size = def_card_size
} }
@@ -41,7 +41,7 @@ const expand = i => {
} }
save() save()
} }
const shrink = i => { const shrink = (i: number) => {
if (cards[i].size === undefined) { if (cards[i].size === undefined) {
cards[i].size = def_card_size cards[i].size = def_card_size
} }
@@ -51,7 +51,7 @@ const shrink = i => {
save() save()
} }
const swap_card = (idx1, idx2) => { const swap_card = (idx1: number, idx2: number) => {
const card1 = cards[idx1] const card1 = cards[idx1]
cards[idx1] = cards[idx2] cards[idx1] = cards[idx2]
cards[idx2] = card1 cards[idx2] = card1
@@ -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

@@ -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,28 @@ 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>
<a class="button" href="https://docs.nova.storage" use:highlight_current_page>
<i class="icon">text_snippet</i>
<span>Documentation</span>
</a>
{/snippet}
</MenuEntry>
<Bookmarks/> <Bookmarks/>
<Tree/> <Tree/>
</nav> </nav>
@@ -238,7 +250,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 +265,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,9 +290,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);
z-index: 9; z-index: 9;
} }
.scroll_container { .scroll_container {
@@ -291,11 +299,16 @@ 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;
backdrop-filter: blur(3px);
} }
.button { .button {
background: none; background: none;
@@ -326,11 +339,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 +355,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 +364,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,14 +103,14 @@ 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)
} }
window.document.title = current_page.title+" / Nova" window.document.title = current_page.title+" / Nova"
if(history) { if (history) {
window.history.pushState({}, window.document.title, pathname) window.history.pushState({}, window.document.title, pathname)
} }
@@ -124,7 +129,7 @@ const load_page = (pathname: string, history: boolean): boolean => {
return true return true
} }
const click = (e: MouseEvent) => { const click = async (e: MouseEvent) => {
const origin = (e.target as Element).closest("a"); const origin = (e.target as Element).closest("a");
if (origin === null) { if (origin === null) {
@@ -140,7 +145,7 @@ const click = (e: MouseEvent) => {
// Try to load the page, if the page was found we cancel the browser // Try to load the page, if the page was found we cancel the browser
// navigation event so we can handle it ourselves // navigation event so we can handle it ourselves
if (load_page(url.pathname, true)) { if (await load_page(url.pathname, true) === true) {
e.preventDefault() e.preventDefault()
} }
} }
@@ -160,7 +165,7 @@ const popstate = (e: PopStateEvent) => {
{#if current_page !== null} {#if current_page !== null}
<current_page.component /> <current_page.component />
{#if current_page.footer === undefined || current_page.footer === true} {#if current_page.footer !== false}
<Footer/> <Footer/>
{/if} {/if}
{/if} {/if}

2
web.go
View File

@@ -4,8 +4,8 @@ import (
"os" "os"
"fornaxian.tech/config" "fornaxian.tech/config"
"fornaxian.tech/fnx_web/webcontroller"
"fornaxian.tech/log" "fornaxian.tech/log"
"fornaxian.tech/nova_web/webcontroller"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
) )

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,20 +25,31 @@ func (wc *WebController) serveDirectory(w http.ResponseWriter, r *http.Request,
return return
} }
node, err := td.PixelAPI.GetFilesystemPath(path) var pdapi = wc.api.RealIP(util.RemoteAddress(r)).RealAgent(r.UserAgent())
if apikey, err := wc.getAPIKey(r); err == nil {
pdapi = pdapi.Login(apikey)
}
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 {
// Set the proper response code for the error message
switch apiErr.StatusCode { switch apiErr.StatusCode {
case "not_found", "path_not_found": case "not_found", "path_not_found":
wc.serveNotFound(w, r) w.WriteHeader(http.StatusNotFound)
case "forbidden": case "forbidden", "permission_denied":
wc.serveForbidden(w, r) w.WriteHeader(http.StatusForbidden)
case "unavailable_for_legal_reasons":
w.WriteHeader(http.StatusUnavailableForLegalReasons)
case "authentication_required": case "authentication_required":
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
case "unavailable_for_legal_reasons": return
wc.serveUnavailableForLegalReasons(w, r) }
case "permission_denied":
wc.serveForbidden(w, r) // Let the JS figure it out
if err = wc.templates.Run(w, r, "wrap", td); err != nil {
log.Error("Failed to run template: %s", err)
} }
} else { } else {
log.Error("Failed to get path: %s", err) log.Error("Failed to get path: %s", err)
@@ -55,19 +66,3 @@ func (wc *WebController) serveDirectory(w http.ResponseWriter, r *http.Request,
log.Error("Error executing template filesystem: %s", err) log.Error("Error executing template filesystem: %s", err)
} }
} }
func (wc *WebController) serveForbidden(w http.ResponseWriter, r *http.Request) {
log.Debug("Forbidden: %s", r.URL)
w.WriteHeader(http.StatusForbidden)
wc.templates.Run(w, r, "403", wc.newTemplateData(w, r))
}
func (wc *WebController) serveNotFound(w http.ResponseWriter, r *http.Request) {
log.Debug("Not Found: %s", r.URL)
w.WriteHeader(http.StatusNotFound)
wc.templates.Run(w, r, "404", wc.newTemplateData(w, r))
}
func (wc *WebController) serveUnavailableForLegalReasons(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnavailableForLegalReasons)
wc.templates.Run(w, r, "451", wc.newTemplateData(w, 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