Rewrite user home page in svelte

This commit is contained in:
2021-09-21 21:39:28 +02:00
parent 8ec548351e
commit 11132854b2
18 changed files with 817 additions and 52 deletions

View File

@@ -32,6 +32,7 @@ export default [
"file_viewer",
"filesystem",
"modal",
"user_home",
"user_buckets",
"user_file_manager",
"admin_panel",

View File

@@ -25,42 +25,44 @@ onMount(() => {
</script>
<div>
<a class="button"
href="/admin"
class:button_highlight={page === "status"}
on:click|preventDefault={() => {navigate("status", "Status")}}>
<i class="icon">home</i>
Status
</a>
<a class="button" href="/admin/abuse">
<i class="icon">block</i>
Block files
</a>
<a class="button"
href="/admin/abuse_reports"
class:button_highlight={page === "abuse_reports"}
on:click|preventDefault={() => {navigate("abuse_reports", "Abuse reports")}}>
<i class="icon">flag</i>
User abuse reports
</a>
<a class="button"
href="/admin/abuse_reporters"
class:button_highlight={page === "abuse_reporters"}
on:click|preventDefault={() => {navigate("abuse_reporters", "Abuse reporters")}}>
<i class="icon">report</i>
E-mail abuse reporters
</a>
<a class="button"
href="/admin/ip_bans"
class:button_highlight={page === "ip_bans"}
on:click|preventDefault={() => {navigate("ip_bans", "IP bans")}}>
<i class="icon">remove_circle</i>
IP bans
</a>
<a class="button" href="/admin/globals">
<i class="icon">edit</i>
Update global settings
</a>
<div class="tab_bar">
<a class="button tab"
href="/admin"
class:button_highlight={page === "status"}
on:click|preventDefault={() => {navigate("status", "Status")}}>
<i class="icon">home</i>
Status
</a>
<a class="button tab" href="/admin/abuse">
<i class="icon">block</i>
Block files
</a>
<a class="button tab"
href="/admin/abuse_reports"
class:button_highlight={page === "abuse_reports"}
on:click|preventDefault={() => {navigate("abuse_reports", "Abuse reports")}}>
<i class="icon">flag</i>
User abuse reports
</a>
<a class="button tab"
href="/admin/abuse_reporters"
class:button_highlight={page === "abuse_reporters"}
on:click|preventDefault={() => {navigate("abuse_reporters", "Abuse reporters")}}>
<i class="icon">report</i>
E-mail abuse reporters
</a>
<a class="button tab"
href="/admin/ip_bans"
class:button_highlight={page === "ip_bans"}
on:click|preventDefault={() => {navigate("ip_bans", "IP bans")}}>
<i class="icon">remove_circle</i>
IP bans
</a>
<a class="button tab" href="/admin/globals">
<i class="icon">edit</i>
Update global settings
</a>
</div>
<hr/>
{#if page === "status"}

8
svelte/src/user_home.js Normal file
View File

@@ -0,0 +1,8 @@
import App from './user_home/Router.svelte';
const app = new App({
target: document.getElementById("page_content"),
props: {}
});
export default app;

View File

@@ -0,0 +1,126 @@
<script>
import Form from "./../util/Form.svelte";
let password_change = {
name: "password_change",
fields: [
{
name: "old_password",
label: "Old password",
type: "current_password",
}, {
name: "new_password",
label: "New password",
type: "new_password",
}, {
name: "new_password2",
label: "New password again",
type: "new_password",
description: "we need you to repeat your password so you " +
"won't be locked out of your account if you make a " +
"typing error"
},
],
submit_label: `<i class="icon">save</i> Save`,
on_submit: async fields => {
if (fields.new_password != fields.new_password2) {
return {success: false, message: "Passwords do not match! Please enter the same password in both fields"}
}
const form = new FormData()
form.append("old_password", fields.old_password)
form.append("new_password", fields.new_password)
const resp = await fetch(
window.api_endpoint+"/user/password",
{ method: "PUT", body: form }
);
if(resp.status >= 400) {
return {error_json: await resp.json()}
}
return {success: true, message: "Success! Your password has been updated"}
},
}
let email_change = {
name: "email_change",
fields: [
{
name: "new_email",
label: "New e-mail address",
type: "email",
default_value: window.user.email,
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 message is clicked. If the e-mail doesn't arrive
right away please check your spam box too. Leave the field
empty to remove your current e-mail address from your
account`,
},
],
submit_label: `<i class="icon">save</i> Save`,
on_submit: async fields => {
const form = new FormData()
form.append("new_email", fields.new_email)
const resp = await fetch(
window.api_endpoint+"/user/email_reset",
{ method: "PUT", body: form }
);
if(resp.status >= 400) {
return {error_json: await resp.json()}
}
return {success: true, message: "Success! E-mail sent. Click the link in the message to verify your new address"}
},
}
let name_change = {
name: "name_change",
fields: [
{
name: "new_username",
label: "New name",
type: "username",
description: `changing your username also changes the name used to
log in. If you forget your username you can still log in using
your e-mail address if you have one configured`,
},
],
submit_label: `<i class="icon">save</i> Save`,
on_submit: async fields => {
const form = new FormData()
form.append("new_username", fields.new_username)
const resp = await fetch(
window.api_endpoint+"/user/username",
{ method: "PUT", body: form }
);
if(resp.status >= 400) {
return {error_json: await resp.json()}
}
return {success: true, message: "Success! You are now known as "+fields.new_username}
},
}
</script>
<div>
<div class="limit_width">
<h2>Change password</h2>
<div class="highlight_dark">
<Form config={password_change}></Form>
</div>
<h2>Change e-mail address</h2>
<div class="highlight_dark">
<Form config={email_change}></Form>
</div>
<h2>Change name</h2>
<div class="highlight_dark">
<Form config={name_change}></Form>
</div>
</div>
</div>
<style>
</style>

View File

@@ -0,0 +1,286 @@
<script>
import { onDestroy, onMount } from "svelte";
import { formatDataVolume, formatThousands } from "../util/Formatting.svelte";
import Chart from "../util/Chart.svelte";
let graph_view = null
let graph_download = null
let graph_bandwidth = null
let graph_direct_link = null
let time_start = ""
let time_end = ""
let total_views = 0
let total_downloads = 0
let total_bandwidth = 0
let total_direct_link = 0
let load_graph = (graph, stat, minutes, interval) => {
let today = new Date()
let start = new Date()
start.setMinutes(start.getMinutes() - minutes)
fetch(
window.api_endpoint + "/user/time_series/" + stat +
"?start=" + start.toISOString() +
"&end=" + today.toISOString() +
"&interval=" + interval
).then(resp => {
if (!resp.ok) { return Promise.reject("Error: " + resp.status); }
return resp.json();
}).then(resp => {
resp.timestamps.forEach((val, idx) => {
let date = new Date(val);
let dateStr = ("00" + (date.getMonth() + 1)).slice(-2);
dateStr += "-" + ("00" + date.getDate()).slice(-2);
dateStr += " " + ("00" + date.getHours()).slice(-2);
dateStr += ":" + ("00" + date.getMinutes()).slice(-2);
resp.timestamps[idx] = " " + dateStr + " "; // Poor man's padding
});
graph.chart().data.labels = resp.timestamps;
graph.chart().data.datasets[0].data = resp.amounts;
graph.chart().update();
time_start = resp.timestamps[0];
time_end = resp.timestamps.slice(-1)[0];
let total = resp.amounts.reduce((acc, cur) => { return acc + cur }, 0)
if (stat == "views") {
total_views = total;
} else if (stat == "downloads") {
total_downloads = total;
} else if (stat == "bandwidth") {
total_bandwidth = total;
} else if (stat == "direct_bandwidth") {
total_direct_link = total;
}
}).catch(e => {
console.error("Error requesting time series: " + e);
})
}
let graph_timeout = null
let graph_timespan = 0
let update_graphs = (minutes, interval, live) => {
if (graph_timeout !== null) { clearTimeout(graph_timeout) }
if (live) {
graph_timeout = setTimeout(() => { update_graphs(minutes, interval, true) }, 10000)
}
graph_timespan = minutes
load_graph(graph_view, "views", minutes, interval)
load_graph(graph_download, "downloads", minutes, interval)
load_graph(graph_bandwidth, "bandwidth", minutes, interval)
load_graph(graph_direct_link, "direct_bandwidth", minutes, interval)
load_direct_bw()
}
let direct_link_bandwidth_used = 0
let direct_link_percent = 0
let storage_space_used = 0
let storage_percent = 0
let load_direct_bw = () => {
let today = new Date()
let start = new Date()
start.setDate(start.getDate() - 30)
fetch(
window.api_endpoint + "/user/time_series/direct_bandwidth" +
"?start=" + start.toISOString() +
"&end=" + today.toISOString() +
"&interval=60"
).then(resp => {
if (!resp.ok) { return Promise.reject("Error: " + resp.status); }
return resp.json();
}).then(resp => {
let total = resp.amounts.reduce((accum, val) => accum += val, 0);
direct_link_bandwidth_used = total
direct_link_percent = total / window.user.subscription.direct_linking_bandwidth
storage_space_used = window.user.storage_space_used
storage_percent = window.user.storage_space_used / window.user.subscription.storage_space
}).catch(e => {
console.error("Error requesting time series: " + e);
})
}
onMount(() => {
update_graphs(1440, 1, true);
})
onDestroy(() => {
if (graph_timeout !== null) {
clearTimeout(graph_timeout)
}
})
</script>
<div>
<div class="limit_width">
<h2>Account information</h2>
<ul>
<li>Username: {window.user.username}</li>
<li>E-mail address: {window.user.email}</li>
<li>
Supporter level: {window.user.subscription.name}
{#if window.user.subscription.type === "patreon"}
(<a href="https://www.patreon.com/join/pixeldrain/checkout?edit=1">Manage subscription</a>)
{/if}
<ul>
<li>
Advertisements when viewing files:
{#if window.user.subscription.disable_ad_display}No{:else}Yes{/if}
</li>
<li>
Advertisements on your uploaded files:
{#if window.user.subscription.disable_ads_on_files}No{:else}Yes{/if}
</li>
{#if window.user.subscription.file_expiry_days > 0}
<li>Files expire after {window.user.subscription.file_expiry_days} days</li>
{:else}
<li>Files never expire</li>
{/if}
</ul>
</li>
</ul>
Storage:
{formatDataVolume(storage_space_used, 3)}
out of
{formatDataVolume(window.user.subscription.storage_space, 3)}
<br/>
<div class="progress_bar_outer">
<div id="storage_progress" class="progress_bar_inner" style="width: {storage_percent*100}%;"></div>
</div>
<br/>
Direct link bandwidth:
{formatDataVolume(direct_link_bandwidth_used, 3)}
out of
{formatDataVolume(window.user.subscription.direct_linking_bandwidth, 3)}
(<a href="/#direct_linking">More information about direct linking</a>)
<br/>
<div class="progress_bar_outer">
<div id="direct_bandwidth_progress" class="progress_bar_inner" style="width: {direct_link_percent*100}%;"></div>
</div>
<h3>Exports</h3>
<div style="text-align: center;">
<a href="/user/export/files" class="button">
<i class="icon">list</i>
Export uploaded files to CSV
</a>
<a href="/user/export/lists" class="button">
<i class="icon">list</i>
Export created lists to CSV
</a>
</div>
<h2>Statistics</h2>
<p>
Here you can see how often your files are viewed, downloaded
and how much bandwidth they consume. The buttons at the top
can be pressed to adjust the timeframe. If you choose 'Day'
the statistics will be updated periodically. No need to
refresh the page.
</p>
</div>
<div class="highlight_dark">
<button
on:click={() => { update_graphs(1440, 1, true) }}
class:button_highlight={graph_timespan == 1440}>
Day (1m)
</button>
<button
on:click={() => { update_graphs(10080, 10, false) }}
class:button_highlight={graph_timespan == 10080}>
Week (10m)
</button>
<button
on:click={() => { update_graphs(20160, 60, false) }}
class:button_highlight={graph_timespan == 20160}>
Two Weeks (1h)
</button>
<button
on:click={() => { update_graphs(43200, 1440, false) }}
class:button_highlight={graph_timespan == 43200}>
Month (1d)
</button>
<button
on:click={() => { update_graphs(131400, 1440, false) }}
class:button_highlight={graph_timespan == 131400}>
Quarter (1d)
</button>
<button
on:click={() => { update_graphs(525600, 1440, false) }}
class:button_highlight={graph_timespan == 525600}>
Year (1d)
</button>
<button
on:click={() => { update_graphs(1051200, 1440, false) }}
class:button_highlight={graph_timespan == 1051200}>
Two Years (1d)
</button>
<br/>
Total usage from {time_start} to {time_end}<br/>
{formatThousands(total_views)} views,
{formatThousands(total_downloads)} downloads,
{formatDataVolume(total_bandwidth, 3)} bandwidth and
{formatDataVolume(total_direct_link, 3)} direct link bandwidth
</div>
<div class="limit_width">
<h3>Views</h3>
<p>
A view is counted when someone visits the download page of one
of your files. Views are unique per user per file.
</p>
</div>
<Chart bind:this={graph_view} dataType="number" label="Views" />
<div class="limit_width">
<h3>Downloads</h3>
<p>
Downloads are counted when a user clicks the download button
on one of your files. It does not matter whether the
download is completed or not, only the start of the download
is counted.
</p>
</div>
<Chart bind:this={graph_download} dataType="number" label="Downloads" />
<div class="limit_width">
<h3>Bandwidth</h3>
<p>
This is how much bandwidth your files are using in total.
Bandwidth is used when a file is tranferred from a
pixeldrain server to a user who is downloading the file.
When a 5 MB file is downloaded 8 times it has used 40 MB of
bandwidth.
</p>
</div>
<Chart bind:this={graph_bandwidth} dataType="bytes" label="Bandwidth" />
<div class="limit_width">
<h3>Direct link bandwidth</h3>
<p>
When a file is downloaded without going through pixeldrain's
download page it counts as a direct download. Because direct
downloads cost us bandwidth and don't generate any ad
revenue we have to limit them. When your direct link
bandwidth runs out people will be asked to do a test before
they can download your files. See our
<a href="/#pro">subscription options</a> to get more direct
linking bandwidth.
</p>
</div>
<Chart bind:this={graph_direct_link} dataType="bytes" label="Direct link bandwidth" />
</div>
<style>
.progress_bar_outer {
background-color: var(--layer_1_color);
width: 100%;
height: 3px;
}
.progress_bar_inner {
background-color: var(--highlight_color);
height: 100%;
width: 0;
transition: width 1s;
}
</style>

View File

@@ -0,0 +1,50 @@
<script>
import { onMount } from "svelte";
import Home from "./Home.svelte";
import AccountSettings from "./AccountSettings.svelte";
let page = ""
let navigate = (path, title) => {
page = path
window.document.title = title+" ~ pixeldrain"
window.history.pushState(
{}, window.document.title, "/user/"+path
)
}
onMount(() => {
let newpage = window.location.pathname.substring(window.location.pathname.lastIndexOf("/")+1)
if (newpage === "user") {
newpage = ""
}
page = newpage
})
</script>
<div>
<a class="button tab"
href="/user"
class:button_highlight={page === ""}
on:click|preventDefault={() => {navigate("", "My home")}}>
<i class="icon">home</i>
My home
</a>
<a class="button tab"
href="/user/settings"
class:button_highlight={page === "settings"}
on:click|preventDefault={() => {navigate("settings", "Account settings")}}>
<i class="icon">settings</i>
Account settings
</a>
<hr/>
{#if page === ""}
<Home></Home>
{:else if page === "settings"}
<AccountSettings></AccountSettings>
{/if}
</div>
<style>
</style>

View File

@@ -15,7 +15,7 @@ export const update = () => {
return chart_object.update()
}
Chart.defaults.global.defaultFontColor = "#b3b3b3";
Chart.defaults.global.defaultFontColor = "#cccccc";
Chart.defaults.global.defaultFontSize = 15;
Chart.defaults.global.defaultFontFamily = "system-ui, sans-serif";
Chart.defaults.global.maintainAspectRatio = false;

229
svelte/src/util/Form.svelte Normal file
View File

@@ -0,0 +1,229 @@
<script>
import { onMount } from "svelte";
import Spinner from "./Spinner.svelte";
export let config = {
fields: [
{
name: "",
label: "",
type: "",
default_value: "",
binding: null,
}
],
submit_label: "",
submit_red: false,
on_submit: async field_values => {},
}
onMount(() => {
config.fields.forEach(field => {
if(field.default_value === undefined) {
field.default_value = ""
}
})
})
let loading = false
let submitted = false
let submit_result = {
success: false,
message: "",
messages: null,
}
let submit = async (event) => {
loading = true
event.preventDefault()
let field_values = {}
config.fields.forEach(val => {
field_values[val.name] = val.binding.value
})
submit_result = await config.on_submit(field_values)
if (submit_result.error_json) {
submit_result = handle_errors(submit_result.error_json)
}
submitted = true
loading = false
return false
}
let field_label = (field) => {
let label = ""
config.fields.forEach(val => {
if (val.name === field) {
label = val.label
}
})
return label
}
let handle_errors = (response) => {
console.log(response)
let result = {success: false, message: "", messages: null}
if (response.value === "multiple_errors") {
result.messages = []
response.errors.forEach(err => {
if (err.value === "string_out_of_range") {
result.messages.push(
`${field_label(err.extra.field)} is too long or too short.
It should be between ${err.extra.min_len} and
${err.extra.max_len} characters. Current length:
${err.extra.len}`
)
} else if (err.value === "field_contains_illegal_character") {
result.messages.push(
`Character '${err.extra.char}' is not allowed in ${field_label(err.extra.field)}`
)
} else if (err.value === "missing_field") {
result.messages.push(
`${field_label(err.extra.field)} is required`
)
} else {
result.messages.push(err.message)
}
})
} else {
result.message = response.message
}
return result
}
</script>
<form method="POST" on:submit={submit}>
{#if loading}
<div class="spinner_container">
<Spinner></Spinner>
</div>
{/if}
{#if submitted}
{#if submit_result.messages}
<div id="submit_result" class:highlight_green={submit_result.success} class:highlight_red={!submit_result.success}>
Something went wrong, please correct these errors before continuing:<br/>
<ul>
{#each submit_result.messages as message}
<li>{message}</li>
{/each}
</ul>
</div>
{:else}
<div id="submit_result" class:highlight_green={submit_result.success} class:highlight_red={!submit_result.success}>
{submit_result.message}
</div>
{/if}
{/if}
<table class="form">
{#each config.fields as field}
<tr class="form">
<td>{field.label}</td>
{#if field.type === "text"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
type="text"
class="form_input"/>
{:else if field.type === "number"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
type="number"
class="form_input"/>
{:else if field.type === "username"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
type="text"
autocomplete="username"
class="form_input"/>
{:else if field.type === "email"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
type="email"
autocomplete="email"
class="form_input"/>
{:else if field.type === "current_password"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
type="password"
autocomplete="current-password"
class="form_input"/>
{:else if field.type === "new_password"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
type="password"
autocomplete="new-password"
class="form_input"/>
{:else if field.type === "captcha"}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<div class="g-recaptcha" data-theme="dark" data-sitekey="{field.captcha_site_key}"></div>
{:else if field.type === "radio"}
{#each field.radio_values as val}
<input
id="input_{field.name}_choice_{val}"
name="{field.name}"
value="{val}"
type="radio"
checked={val === field.default_value}/>
<label for="input_{field.name}_choice_{val}">{val}</label><br/>
{/each}
{:else if field.type === "description"}
{field.default_value}
{/if}
</tr>
{#if field.description}
<tr class="form">
<td colspan="2">
{field.description}
</td>
</tr>
{/if}
{#if field.separator}
<tr class="form">
<td colspan="2">
<hr/>
</td>
</tr>
{/if}
{/each}
<!-- Submit button -->
<tr class="form">
<td colspan="2" style="text-align: right;">
{#if config.submit_red}
<button type="submit" class="button_red" style="float: right;">{@html config.submit_label}</button>
{:else}
<button type="submit" class="button_highlight" style="float: right;">{@html config.submit_label}</button>
{/if}
</td>
</tr>
</table>
</form>
<style>
.spinner_container {
position: absolute;
top: 10px;
left: 10px;
height: 100px;
width: 100px;
z-index: 1000;
}
</style>