Add svelte as a view engine to swaf #33
37
src/assets/ts/WebsocketClient.ts
Normal file
37
src/assets/ts/WebsocketClient.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export default class WebsocketClient {
|
||||
|
||||
/**
|
||||
* @param websocketUrl
|
||||
* @param listener
|
||||
* @param reconnectOnCloseAfter time to reconnect after connection fail in ms. -1 to not reconnect automatically.
|
||||
* @param checkFunction
|
||||
*/
|
||||
public constructor(
|
||||
private readonly websocketUrl: string,
|
||||
private readonly listener: (websocket: WebSocket, e: MessageEvent) => void,
|
||||
private readonly reconnectOnCloseAfter: number = 1000,
|
||||
) {
|
||||
}
|
||||
|
||||
public run(): void {
|
||||
const websocket = new WebSocket(this.websocketUrl);
|
||||
websocket.onopen = () => {
|
||||
console.debug('Websocket connected');
|
||||
};
|
||||
websocket.onmessage = (e) => {
|
||||
this.listener(websocket, e);
|
||||
};
|
||||
websocket.onerror = (e) => {
|
||||
console.error('Websocket error', e);
|
||||
};
|
||||
websocket.onclose = (e) => {
|
||||
console.debug('Websocket closed', e.code, e.reason);
|
||||
|
||||
if (this.reconnectOnCloseAfter >= 0) {
|
||||
setTimeout(() => {
|
||||
this.run();
|
||||
}, this.reconnectOnCloseAfter);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
27
src/assets/views/auth/account/NamePanel.svelte
Normal file
27
src/assets/views/auth/account/NamePanel.svelte
Normal file
@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import {locals} from "../../../ts/stores";
|
||||
import Message from "../../components/Message.svelte";
|
||||
import Form from "../../utils/Form.svelte";
|
||||
import Field from "../../utils/Field.svelte";
|
||||
|
||||
let newName = '';
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h2><i data-feather="key"></i> Change name</h2>
|
||||
|
||||
|
||||
{#if $locals.can_change_name}
|
||||
<Form action={$locals.route('change-name')}
|
||||
submitIcon="save" submitText="Change my name {newName.length > 0 ? 'to ' + newName : ''}"
|
||||
confirm="Are you sure you want to change your name to {newName}?">
|
||||
<Field type="text" name="name" placeholder="New name" required bind:value={newName}/>
|
||||
|
||||
<Field type="checkbox" name="terms"
|
||||
placeholder="I understand that I can only change my name once every {$locals.name_change_wait_period}"
|
||||
required/>
|
||||
</Form>
|
||||
{:else}
|
||||
<Message type="info" content="You can change your name in {$locals.can_change_name_in}"/>
|
||||
{/if}
|
||||
</section>
|
30
src/assets/views/auth/account/PasswordPanel.svelte
Normal file
30
src/assets/views/auth/account/PasswordPanel.svelte
Normal file
@ -0,0 +1,30 @@
|
||||
<script>
|
||||
import {locals} from "../../../ts/stores";
|
||||
import Form from "../../utils/Form.svelte";
|
||||
import Field from "../../utils/Field.svelte";
|
||||
|
||||
let removePasswordMode = false;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h2><i data-feather="key"></i> {$locals.has_password ? 'Change' : 'Set'} password</h2>
|
||||
|
||||
{#if removePasswordMode}
|
||||
<Form action={$locals.route('remove-password')}
|
||||
submitIcon="trash" submitText="Remove password" submitClass="danger"
|
||||
confirm="Are you sure you want to remove your password?">
|
||||
<button type="button" on:click={() => removePasswordMode = false}>Go back</button>
|
||||
</Form>
|
||||
{:else}
|
||||
<Form action={$locals.route('change-password')}
|
||||
submitIcon="save" submitText="Set password">
|
||||
{#if $locals.has_password}
|
||||
<Field type="password" name="current_password" placeholder="Current password"/>
|
||||
<button type="button" on:click={() => removePasswordMode = true}>Forgot your password?</button>
|
||||
{/if}
|
||||
|
||||
<Field type="password" name="new_password" placeholder="New password" required/>
|
||||
<Field type="password" name="new_password_confirmation" placeholder="New password confirmation" required/>
|
||||
</Form>
|
||||
{/if}
|
||||
</section>
|
@ -1,98 +0,0 @@
|
||||
{% extends 'layouts/base.njk' %}
|
||||
{% import 'macros.njk' as macros %}
|
||||
|
||||
{% set title = 'Account' %}
|
||||
{% set decription = 'Manage your account settings and data.' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<h2><i data-feather="user"></i> Personal information</h2>
|
||||
|
||||
{% if display_email_warning and emails | length <= 0 %}
|
||||
{{ macros.message('warning', 'To avoid losing access to your account, please add an email address.') }}
|
||||
{% endif %}
|
||||
|
||||
{% for field in user.getPersonalInfoFields() %}
|
||||
<p>{{ field.name }}: {{ field.value }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% if main_email.email | length > 0 %}
|
||||
<p>Contact email: {{ main_email.email }} <a href="#emails">More...</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if has_name_component %}
|
||||
{% include 'views/auth/account/name_panel.njk' %}
|
||||
{% endif %}
|
||||
|
||||
{% if has_password_component %}
|
||||
{% include 'views/auth/account/password_panel.njk' %}
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2 id="emails"><i data-feather="shield"></i> Email addresses</h2>
|
||||
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Address</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for email in emails %}
|
||||
{% if email.id == user.main_email_id %}
|
||||
<tr>
|
||||
<td>Main</td>
|
||||
<td>{{ email.email }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for email in emails %}
|
||||
{% if email.id != user.main_email_id %}
|
||||
<tr>
|
||||
<td>Secondary</td>
|
||||
<td>{{ email.email }}</td>
|
||||
<td class="actions">
|
||||
<form action="{{ route('set-main-email') }}" method="POST">
|
||||
<input type="hidden" name="id" value="{{ email.id }}">
|
||||
|
||||
<button class="warning"
|
||||
onclick="return confirm('Are you sure you want to set {{ email.email }} as your main address?');">
|
||||
<i data-feather="refresh-ccw"></i> <span class="tip">Set as main address</span>
|
||||
</button>
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
</form>
|
||||
|
||||
<form action="{{ route('remove-email') }}" method="POST">
|
||||
<input type="hidden" name="id" value="{{ email.id }}">
|
||||
|
||||
<button class="danger"
|
||||
onclick="return confirm('Are you sure you want to delete {{ email.email }}?');">
|
||||
<i data-feather="trash"></i> <span class="tip">Remove</span>
|
||||
</button>
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form action="{{ route('add-email') }}" method="POST" class="sub-panel">
|
||||
<h3>Add an email address:</h3>
|
||||
|
||||
{{ macros.field(_locals, 'email', 'email', null, 'Choose a safe email address', 'An email address we can use to identify you in case you lose access to your account', 'required') }}
|
||||
|
||||
<button><i data-feather="plus"></i> Add email address</button>
|
||||
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
98
src/assets/views/auth/account/account.svelte
Normal file
98
src/assets/views/auth/account/account.svelte
Normal file
@ -0,0 +1,98 @@
|
||||
<script>
|
||||
import {locals} from "../../../ts/stores";
|
||||
import BaseLayout from "../../layouts/BaseLayout.svelte";
|
||||
import Message from "../../components/Message.svelte";
|
||||
import NamePanel from "./NamePanel.svelte";
|
||||
import PasswordPanel from "./PasswordPanel.svelte";
|
||||
import Form from "../../utils/Form.svelte";
|
||||
import Field from "../../utils/Field.svelte";
|
||||
|
||||
const mainEmail = $locals.main_email?.email;
|
||||
const personalInfoFields = $locals.user_personal_info_fields || [];
|
||||
const emails = $locals.emails || [];
|
||||
</script>
|
||||
|
||||
<BaseLayout title="Account" description="Manage your account settings and data.">
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<h2><i data-feather="user"></i> Personal information</h2>
|
||||
|
||||
{#if $locals.display_email_warning && $locals.emails.length <= 0}
|
||||
<Message type="warning" content="To avoid losing access to your account, please add an email address."/>
|
||||
{/if}
|
||||
|
||||
{#each personalInfoFields as field}
|
||||
<p>{field.name}: {field.value}</p>
|
||||
{/each}
|
||||
|
||||
{#if mainEmail}
|
||||
<p>Contact email: {mainEmail} <a href="#emails">More...</a></p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $locals.has_name_component}
|
||||
<NamePanel/>
|
||||
{/if}
|
||||
|
||||
{#if $locals.has_password_component}
|
||||
<PasswordPanel/>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<h2 id="emails"><i data-feather="shield"></i> Email addresses</h2>
|
||||
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Address</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#each emails as email}
|
||||
{#if email.id === $locals.user.main_email_id}
|
||||
<tr>
|
||||
<td>Main</td>
|
||||
<td>{email.email}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#each emails as email}
|
||||
{#if email.id !== $locals.user.main_email_id}
|
||||
<tr>
|
||||
<td>Secondary</td>
|
||||
<td>{email.email}</td>
|
||||
<td class="actions">
|
||||
<Form action={$locals.route('set-main-email')} button
|
||||
submitIcon="refresh-ccw" submitText="Set as main address" submitClass="warning"
|
||||
confirm="Are you sure you want to set {email.email} as your main address?">
|
||||
<Field type="hidden" name="id" value={email.id}/>
|
||||
</Form>
|
||||
|
||||
<Form action={$locals.route('remove-email')} button
|
||||
submitIcon="trash" submitText="Remove" submitClass="danger"
|
||||
confirm="Are you sure you want to delete {email.email}?">
|
||||
<Field type="hidden" name="id" value={email.id}/>
|
||||
</Form>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Form action={$locals.route('add-email')} class="sub-panel"
|
||||
submitIcon="plus" submitText="Add email address">
|
||||
<h3>Add an email address:</h3>
|
||||
|
||||
<Field type="email" name="email" placeholder="Choose a safe email address"
|
||||
hint="An email address we can use to identify you in case you lose access to your account"
|
||||
required/>
|
||||
</Form>
|
||||
</section>
|
||||
</div>
|
||||
</BaseLayout>
|
@ -1,16 +0,0 @@
|
||||
<section class="panel">
|
||||
<h2><i data-feather="key"></i> Change name</h2>
|
||||
{% if can_change_name %}
|
||||
<form action="{{ route('change-name') }}" method="POST">
|
||||
{{ macros.field(_locals, 'text', 'name', null, 'New name', null, 'required') }}
|
||||
|
||||
{{ macros.field(_locals, 'checkbox', 'terms', null, 'I understand that I can only change my name once every ' + name_change_wait_period, '', 'required') }}
|
||||
|
||||
<button type="submit"><i data-feather="save"></i> Confirm</button>
|
||||
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
</form>
|
||||
{% else %}
|
||||
{{ macros.message('info', 'You can change your name in ' + can_change_name_in) }}
|
||||
{% endif %}
|
||||
</section>
|
@ -1,45 +0,0 @@
|
||||
<section class="panel">
|
||||
<h2><i data-feather="key"></i> {% if has_password %}Change{% else %}Set{% endif %} password</h2>
|
||||
|
||||
<form action="{{ route('change-password') }}" method="POST" id="change-password-form">
|
||||
{% if has_password %}
|
||||
{{ macros.field(_locals, 'password', 'current_password', null, 'Current password') }}
|
||||
<p><a href="javascript: void(0);" class="switch-form-link">Forgot your password?</a></p>
|
||||
{% endif %}
|
||||
{{ macros.field(_locals, 'password', 'new_password', null, 'New password') }}
|
||||
{{ macros.field(_locals, 'password', 'new_password_confirmation', null, 'New password confirmation') }}
|
||||
|
||||
<button type="submit"><i data-feather="save"></i> Save</button>
|
||||
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
</form>
|
||||
|
||||
{% if has_password %}
|
||||
<form action="{{ route('remove-password') }}" method="POST" id="remove-password-form" class="hidden">
|
||||
<p><a href="javascript: void(0);" class="switch-form-link">Go back</a></p>
|
||||
|
||||
<button type="submit" class="danger"><i data-feather="trash"></i> Remove password</button>
|
||||
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const changePasswordForm = document.getElementById('change-password-form');
|
||||
const removePasswordLink = changePasswordForm.querySelector('a.switch-form-link');
|
||||
const removePasswordForm = document.getElementById('remove-password-form');
|
||||
const changePasswordLink = removePasswordForm.querySelector('a.switch-form-link');
|
||||
|
||||
removePasswordLink.addEventListener('click', () => {
|
||||
changePasswordForm.classList.add('hidden');
|
||||
removePasswordForm.classList.remove('hidden');
|
||||
});
|
||||
|
||||
changePasswordLink.addEventListener('click', () => {
|
||||
removePasswordForm.classList.add('hidden');
|
||||
changePasswordForm.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
</section>
|
68
src/assets/views/auth/auth.svelte
Normal file
68
src/assets/views/auth/auth.svelte
Normal file
@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import {locals} from "../../ts/stores.js";
|
||||
import BaseLayout from "../layouts/BaseLayout.svelte";
|
||||
import Form from "../utils/Form.svelte";
|
||||
import Field from "../utils/Field.svelte";
|
||||
|
||||
let registerUsingMagicLink = $locals.previousFormData()?.['auth_method'] !== 'password';
|
||||
let loginUsingMagicLink = true;
|
||||
|
||||
let queryStr = '';
|
||||
let previousUrl = $locals.getPreviousUrl();
|
||||
if ($locals.query?.redirect_uri) {
|
||||
queryStr = '?' + new URLSearchParams({redirect_uri: $locals.query?.redirect_uri}).toString();
|
||||
} else if (previousUrl) {
|
||||
queryStr = '?' + new URLSearchParams({redirect_uri: previousUrl}).toString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<BaseLayout title="Authentication / Registration"
|
||||
description="Join {$locals.app.name} and share your files!"
|
||||
h1="Authentication and registration">
|
||||
|
||||
<div class="container">
|
||||
<section class="panel">
|
||||
<h2><i data-feather="log-in"></i> Log in</h2>
|
||||
|
||||
<Form action={$locals.route('login') + queryStr} submitText="Authenticate" submitIcon="log-in">
|
||||
<Field type="text" name="identifier" value={$locals.query?.identifier}
|
||||
placeholder="Your email address or username" required/>
|
||||
|
||||
{#if !loginUsingMagicLink}
|
||||
<Field type="password" name="password" placeholder="Your password" required/>
|
||||
<button on:click={() => loginUsingMagicLink=true} type="button">Use magic link</button>
|
||||
{:else}
|
||||
<button on:click={() => loginUsingMagicLink=false} type="button">Use password</button>
|
||||
{/if}
|
||||
<Field type="checkbox" name="persist_session" placeholder="Stay logged in on this computer."/>
|
||||
</Form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2><i data-feather="user-plus"></i> Register</h2>
|
||||
|
||||
<Form action={$locals.route('register') + queryStr} submitText="Register" submitIcon="check">
|
||||
<Field type="hidden" name="auth_method" value={registerUsingMagicLink ? 'magic_link': 'password'}/>
|
||||
|
||||
{#if $locals.has_username}
|
||||
<Field type="text" name={registerUsingMagicLink ? 'name' : 'identifier'}
|
||||
placeholder="Choose your username" hint="This cannot be changed later."
|
||||
pattern="[0-9a-z_-]+" required/>
|
||||
{/if}
|
||||
|
||||
{#if registerUsingMagicLink}
|
||||
<Field type="email" name="identifier" placeholder="Your email address" required/>
|
||||
<button on:click={() => registerUsingMagicLink=false} type="button">Use password instead</button>
|
||||
{:else}
|
||||
<Field type="password" name="password" placeholder="Choose a password" required/>
|
||||
<Field type="password" name="password_confirmation" placeholder="Confirm your password" required/>
|
||||
<button on:click={() => registerUsingMagicLink=true} type="button">Use email address instead</button>
|
||||
{/if}
|
||||
|
||||
<Field type="checkbox" name="terms" required>
|
||||
I accept the <a href="/terms-of-services" target="_blank">Terms Of Services</a>.
|
||||
</Field>
|
||||
</Form>
|
||||
</section>
|
||||
</div>
|
||||
</BaseLayout>
|
@ -1,63 +0,0 @@
|
||||
{% extends 'layouts/base.njk' %}
|
||||
|
||||
{% set title = app.name + ' - Review accounts' %}
|
||||
|
||||
{% block body %}
|
||||
<h1>Accounts pending review</h1>
|
||||
|
||||
<div class="panel">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="shrink-col">#</th>
|
||||
{% if has_user_name_component %}
|
||||
<th>Name</th>
|
||||
{% endif %}
|
||||
<th>Main email</th>
|
||||
<th>Registered at</th>
|
||||
<th class="shrink-col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in accounts %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
{% if has_user_name_component %}
|
||||
<td>{{ user.name }}</td>
|
||||
{% endif %}
|
||||
<td>{{ user.mainEmail.getOrFail().email | default('No email') }}</td>
|
||||
<td>{{ user.created_at.toISOString() }}</td>
|
||||
<td>
|
||||
<div class="max-content">
|
||||
<form action="{{ route('approve-account') }}" method="POST">
|
||||
<input type="hidden" name="user_id" value="{{ user.id }}">
|
||||
<button class="success"><i data-feather="check"></i> Approve</button>
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
</form>
|
||||
|
||||
<form action="{{ route('reject-account') }}" method="POST"
|
||||
data-confirm="This will irrevocably delete the {{ user.mainEmail.getOrFail().email | default(user.name | default(user.id)) }} account.">
|
||||
<input type="hidden" name="user_id" value="{{ user.id }}">
|
||||
<button class="danger"><i data-feather="check"></i> Reject</button>
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('form[data-confirm]').forEach(el => {
|
||||
el.addEventListener('submit', e => {
|
||||
if (!confirm(el.dataset['confirm'])) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
69
src/assets/views/backend/accounts_approval.svelte
Normal file
69
src/assets/views/backend/accounts_approval.svelte
Normal file
@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import {locals} from "../../ts/stores";
|
||||
import BaseLayout from "../layouts/BaseLayout.svelte";
|
||||
import Pagination from "../components/Pagination.svelte";
|
||||
import Form from "../utils/Form.svelte";
|
||||
import Field from "../utils/Field.svelte";
|
||||
|
||||
const accounts = $locals.accounts || [];
|
||||
</script>
|
||||
|
||||
<style>
|
||||
td.empty {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<BaseLayout title="{$locals.app.name} - Review accounts" h1={false}>
|
||||
<h1>Accounts pending review</h1>
|
||||
|
||||
<Pagination pagination={$locals.pagination} routeName="accounts-approval" contextSize="3" />
|
||||
|
||||
<div class="panel">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="shrink-col">#</th>
|
||||
{#if $locals.has_user_name_component}
|
||||
<th>Name</th>
|
||||
{/if}
|
||||
<th>Main email</th>
|
||||
<th>Registered at</th>
|
||||
<th class="shrink-col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each accounts as user}
|
||||
<tr>
|
||||
<td>{user.id}</td>
|
||||
{#if $locals.has_user_name_component}
|
||||
<td>{user.name}</td>
|
||||
{/if}
|
||||
<td>{user.mainEmailStr || 'No email'}</td>
|
||||
<td><time datetime={user.created_at_iso}>{user.created_at_human} ago</time></td>
|
||||
<td>
|
||||
<div class="max-content">
|
||||
<Form action={$locals.route('approve-account')}
|
||||
submitIcon="check" submitText="Approve" submitClass="success">
|
||||
<Field type="hidden" name="user_id" value={user.id}/>
|
||||
</Form>
|
||||
|
||||
<Form action={$locals.route('reject-account')}
|
||||
submitIcon="trash" submitText="Reject" submitClass="danger"
|
||||
confirm="This will irrevocably delete the {user.mainEmailStr || user.name || user.id} account.">
|
||||
<Field type="hidden" name="user_id" value={user.id}/>
|
||||
</Form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="5" class="empty">No account to review.</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination pagination={$locals.pagination} routeName="accounts-approval" contextSize="3" />
|
||||
</BaseLayout>
|
@ -1,28 +0,0 @@
|
||||
{% extends 'layouts/base.njk' %}
|
||||
|
||||
{% set title = app.name + ' backend' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
{{ macros.breadcrumb('Backend') }}
|
||||
|
||||
<h1>App administration</h1>
|
||||
|
||||
<div class="panel">
|
||||
<nav>
|
||||
<ul>
|
||||
{% for element in menu %}
|
||||
<li>
|
||||
<a href="{{ element.link }}">
|
||||
{% if element.display_icon !== null %}
|
||||
<i data-feather="{{ element.display_icon }}"></i>
|
||||
{% endif %}
|
||||
{{ element.display_string }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
32
src/assets/views/backend/index.svelte
Normal file
32
src/assets/views/backend/index.svelte
Normal file
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import {locals} from "../../ts/stores.js";
|
||||
import BaseLayout from "../layouts/BaseLayout.svelte";
|
||||
import Breadcrumb from "../components/Breadcrumb.svelte";
|
||||
|
||||
const menu = $locals.menu || [];
|
||||
</script>
|
||||
|
||||
<BaseLayout title="{$locals.app.name} backend" h1={false}>
|
||||
<div class="container">
|
||||
<Breadcrumb currentPageTitle="Backend"/>
|
||||
|
||||
<h1>App administration</h1>
|
||||
|
||||
<div class="panel">
|
||||
<nav>
|
||||
<ul>
|
||||
{#each menu as element}
|
||||
<li>
|
||||
<a href={element.link}>
|
||||
{#if element.display_icon !== null}
|
||||
<i data-feather={element.display_icon}></i>
|
||||
{/if}
|
||||
{element.display_string}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
13
src/assets/views/components/Breadcrumb.svelte
Normal file
13
src/assets/views/components/Breadcrumb.svelte
Normal file
@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
export let currentPageTitle: string;
|
||||
export let pages: { link: string, title: string }[] = [];
|
||||
</script>
|
||||
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
{#each pages as page}
|
||||
<li><a href={page.link}>{page.title}</a></li>
|
||||
{/each}
|
||||
<li class="active" aria-current="page">{currentPageTitle}</li>
|
||||
</ol>
|
||||
</nav>
|
17
src/assets/views/components/FlashMessages.svelte
Normal file
17
src/assets/views/components/FlashMessages.svelte
Normal file
@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import {locals} from "../../ts/stores.js";
|
||||
import Message from "./Message.svelte";
|
||||
|
||||
export let flashed = $locals.flash();
|
||||
console.log('Flashed:', flashed)
|
||||
</script>
|
||||
|
||||
<div class="messages">
|
||||
{#if flashed}
|
||||
{#each Object.entries(flashed) as [key, bag], i}
|
||||
{#each bag as content}
|
||||
<Message type={key} content={content}/>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
17
src/assets/views/components/Message.svelte
Normal file
17
src/assets/views/components/Message.svelte
Normal file
@ -0,0 +1,17 @@
|
||||
<script>
|
||||
export let type;
|
||||
export let content;
|
||||
export let raw = false;
|
||||
export let discreet = false;
|
||||
</script>
|
||||
|
||||
<div class="message" class:message-discreet={discreet} data-type="{type}">
|
||||
<i class="icon"></i>
|
||||
<span class="content">
|
||||
{#if raw}
|
||||
{@html content}
|
||||
{:else}
|
||||
{content}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
23
src/assets/views/components/NavMenuItem.svelte
Normal file
23
src/assets/views/components/NavMenuItem.svelte
Normal file
@ -0,0 +1,23 @@
|
||||
<script>
|
||||
import CsrfTokenField from "../utils/CsrfTokenField.svelte";
|
||||
|
||||
export let href;
|
||||
export let icon;
|
||||
export let text;
|
||||
export let action = false;
|
||||
</script>
|
||||
|
||||
|
||||
<li>
|
||||
{#if action}
|
||||
<form action={href} method="POST">
|
||||
<button><i data-feather={icon}></i> <span class="tip">{text}</span></button>
|
||||
|
||||
<CsrfTokenField/>
|
||||
</form>
|
||||
{:else}
|
||||
<a href={href}>
|
||||
<i data-feather={icon}></i> <span class="tip">{text}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
46
src/assets/views/components/Pagination.svelte
Normal file
46
src/assets/views/components/Pagination.svelte
Normal file
@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import {route} from "../../../common/Routing.js";
|
||||
import {Pagination} from "../../../common/Pagination.js";
|
||||
|
||||
export let pagination: string;
|
||||
export let routeName: string;
|
||||
export let contextSize: number;
|
||||
|
||||
$: paginationObj = pagination ? Pagination.deserialize(pagination) : null;
|
||||
</script>
|
||||
|
||||
{#if paginationObj && (paginationObj.hasPrevious() || paginationObj.hasNext())}
|
||||
<nav class="pagination">
|
||||
<ul>
|
||||
{#if paginationObj.hasPrevious()}
|
||||
<li><a href={route(routeName, {page: paginationObj.page - 1})}>
|
||||
<i data-feather="chevron-left"></i> Previous
|
||||
</a></li>
|
||||
|
||||
{#each paginationObj.previousPages(contextSize) as i}
|
||||
{#if i === -1}
|
||||
<li class="ellipsis">...</li>
|
||||
{:else}
|
||||
<li><a href={route(routeName, {page: i})}>{i}</a></li>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<li class="active"><span>{paginationObj.page}</span></li>
|
||||
|
||||
{#if paginationObj.hasNext()}
|
||||
{#each paginationObj.nextPages(contextSize) as i}
|
||||
{#if i === -1}
|
||||
<li class="ellipsis">...</li>
|
||||
{:else}
|
||||
<li><a href={route(routeName, {page: i})}>{i}</a></li>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<li><a href={route(routeName, {page: paginationObj.page + 1})}>
|
||||
Next <i data-feather="chevron-right"></i>
|
||||
</a></li>
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
{/if}
|
10
src/assets/views/home.svelte
Normal file
10
src/assets/views/home.svelte
Normal file
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import AllTests from "./AllTests.svelte";
|
||||
import BaseLayout from "./layouts/BaseLayout.svelte";
|
||||
|
||||
let test: string = 'testing';
|
||||
</script>
|
||||
|
||||
<BaseLayout title="Home tests">
|
||||
<AllTests/>
|
||||
</BaseLayout>
|
78
src/assets/views/layouts/BaseLayout.svelte
Normal file
78
src/assets/views/layouts/BaseLayout.svelte
Normal file
@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import {locals} from "../../ts/stores.js";
|
||||
import {route} from "../../../common/Routing.js";
|
||||
import FlashMessages from "../components/FlashMessages.svelte";
|
||||
import NavMenuItem from "../components/NavMenuItem.svelte";
|
||||
|
||||
export let title: string;
|
||||
export let h1: string = title;
|
||||
export let description: string;
|
||||
export let refresh_after: number | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
|
||||
<title>{title || 'Undefined title'}</title>
|
||||
{#if description}
|
||||
<meta name="description" content={description}>
|
||||
{/if}
|
||||
|
||||
<link rel="shortcut icon" type="image/png" href="/img/logox1024.png">
|
||||
<link rel="shortcut icon" type="image/png" href="/img/logox128.png">
|
||||
<link rel="shortcut icon" type="image/svg" href="/img/logo.svg">
|
||||
|
||||
{#if refresh_after}
|
||||
<meta http-equiv="refresh" content={refresh_after}>
|
||||
{/if}
|
||||
|
||||
<link rel="stylesheet" href="/css/bundle.css">
|
||||
</svelte:head>
|
||||
|
||||
<header>
|
||||
<a href="/" class="logo"><img src="/img/logo.svg" alt="Logo"> {$locals.app.name}</a>
|
||||
<nav>
|
||||
<button id="menu-button"><i data-feather="menu"></i></button>
|
||||
<ul id="main-menu">
|
||||
{#if $locals.user}
|
||||
{#if $locals.user.is_admin}
|
||||
<NavMenuItem href={route('backend')} icon="settings" text="Backend"/>
|
||||
{/if}
|
||||
|
||||
<NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'}/>
|
||||
<NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
|
||||
{:else}
|
||||
<NavMenuItem href={route('auth')} icon="log-in" text="Log in / Register"/>
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<FlashMessages/>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
{#if h1}
|
||||
<h1>{h1}</h1>
|
||||
{/if}
|
||||
{#if $$slots.subtitle}
|
||||
<p>
|
||||
<slot name="subtitle"/>
|
||||
</p>
|
||||
{/if}
|
||||
<slot/>
|
||||
</main>
|
||||
|
||||
<footer>{$locals.app.name} v{$locals.app_version} - all rights reserved.</footer>
|
@ -1,37 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ title }}</title>
|
||||
|
||||
<link rel="shortcut icon" type="image/png" href="/img/logox1024.png">
|
||||
<link rel="shortcut icon" type="image/png" href="/img/logox128.png">
|
||||
<link rel="shortcut icon" type="image/svg" href="/img/logo.svg">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
{% if description %}
|
||||
<meta name="description" content="{{ description }}">
|
||||
{% endif %}
|
||||
|
||||
{% if refresh_after %}
|
||||
<meta http-equiv="refresh" content="{{ refresh_after }}">
|
||||
{% endif %}
|
||||
|
||||
{% block _stylesheets %}{% endblock %}
|
||||
{% block _scripts %}
|
||||
<script src="/js/app.js" defer></script>
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
{% block header %}{% endblock %}
|
||||
</header>
|
||||
|
||||
{% block _body %}{% endblock %}
|
||||
|
||||
<footer>{% block footer %}{% endblock %}</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
@ -1,18 +0,0 @@
|
||||
{% extends 'layouts/base.njk' %}
|
||||
|
||||
{% set actionType = magicLink.action_type %}
|
||||
{% set h1 = 'Magic Link' + (' - ' + actionType if actionType) %}
|
||||
{% set title = app.name + ' ' + h1 %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
{% if err %}
|
||||
{{ macros.message('error', err) }}
|
||||
{% else %}
|
||||
{{ macros.message('success', 'Success!') }}
|
||||
<p>You can now close this page.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
21
src/assets/views/magic_link.svelte
Normal file
21
src/assets/views/magic_link.svelte
Normal file
@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import {locals} from "../ts/stores";
|
||||
import BaseLayout from "./layouts/BaseLayout.svelte";
|
||||
import Message from "./components/Message.svelte";
|
||||
|
||||
const actionType = $locals.magicLink?.action_type;
|
||||
const h1 = 'Magic Link' + (actionType ? (' - ' + actionType) : '');
|
||||
</script>
|
||||
|
||||
<BaseLayout title="{$locals.app.name} {h1}" {h1}>
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
{#if $locals.err}
|
||||
<Message type="error" content={$locals.err}/>
|
||||
{:else}
|
||||
<Message type="success" content="Success!"/>
|
||||
<p>You can now close this page.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
51
src/assets/views/magic_link_lobby.svelte
Normal file
51
src/assets/views/magic_link_lobby.svelte
Normal file
@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import {locals} from "../ts/stores.js";
|
||||
import BaseLayout from "./layouts/BaseLayout.svelte";
|
||||
import Message from "./components/Message.svelte";
|
||||
import WebsocketClient from "../ts/WebsocketClient.js";
|
||||
import {Time} from "../../common/Time.js";
|
||||
import {onMount} from "svelte";
|
||||
|
||||
const validUntil = parseFloat($locals.validUntil as string);
|
||||
|
||||
function isValid() {
|
||||
return new Date().getTime() < validUntil;
|
||||
}
|
||||
|
||||
let countdown;
|
||||
let validUntilDate = new Date(validUntil);
|
||||
$: countdown = $locals.isPreRender ? '...' : Time.humanizeTimeTo(validUntilDate);
|
||||
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
validUntilDate = new Date(validUntil);
|
||||
}, 1000);
|
||||
|
||||
if (isValid()) {
|
||||
const webSocket = new WebsocketClient($locals.websocketUrl as string, (websocket, e) => {
|
||||
if (e.data === 'refresh') {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
webSocket.run();
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<BaseLayout h1="Authentication lobby" title="{$locals.app.name} authentication lobby">
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<Message type="success"
|
||||
content={`We sent a link to ${$locals.email}. To authenticate, open it from any device.`}/>
|
||||
<Message type="info" discreet raw
|
||||
content={`This link will be valid for ${countdown} and can only be used once.`}/>
|
||||
|
||||
<p class="center">Waiting for you to open the link...</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
7
src/assets/views/utils/CsrfTokenField.svelte
Normal file
7
src/assets/views/utils/CsrfTokenField.svelte
Normal file
@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {locals} from "../../ts/stores.js";
|
||||
import Field from "./Field.svelte";
|
||||
const token = $locals.getCsrfToken ? $locals.getCsrfToken() : undefined;
|
||||
</script>
|
||||
|
||||
<Field type="hidden" name="csrf" value={token}/>
|
101
src/assets/views/utils/Field.svelte
Normal file
101
src/assets/views/utils/Field.svelte
Normal file
@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import {locals} from '../../ts/stores.js';
|
||||
import Message from "../components/Message.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import {getContext} from "svelte";
|
||||
|
||||
export let type: string;
|
||||
export let name: string;
|
||||
type FieldValue = string | number | Record<string, FieldValue>;
|
||||
export let value: FieldValue | undefined = undefined;
|
||||
export let placeholder: string | undefined = undefined;
|
||||
export let hint: string | undefined = undefined;
|
||||
export let extraData: string[] | undefined = undefined;
|
||||
export let icon: string | undefined = undefined;
|
||||
|
||||
const formId = getContext('formId');
|
||||
const fieldId = `${formId}-${name}-field`;
|
||||
|
||||
const validation = $locals.validation()?.[name];
|
||||
const previousFormData = $locals.previousFormData() || [];
|
||||
|
||||
value = type !== 'hidden' && previousFormData[name] || value || validation?.value || '';
|
||||
|
||||
function durationValue(f: string): number {
|
||||
if (previousFormData[name]) {
|
||||
return value[f];
|
||||
}
|
||||
|
||||
switch (f) {
|
||||
case 's':
|
||||
return value % 60;
|
||||
case 'm':
|
||||
return (value - value % 60) / 60 % 60;
|
||||
case 'h':
|
||||
return (value - value % 3600) / 3600;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
// in here, you can switch on type and implement
|
||||
// whatever behaviour you need
|
||||
value = type.match(/^(number|range)$/)
|
||||
? +e.target.value
|
||||
: e.target.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if type === 'hidden'}
|
||||
{#if validation}
|
||||
<Message type="error" content={validation.message}/>
|
||||
{/if}
|
||||
<input type="hidden" name={name} value={value}>
|
||||
{:else}
|
||||
<div class="form-field" class:inline={type === 'checkbox'}>
|
||||
<div class="control">
|
||||
{#if icon}
|
||||
<Icon name={icon}/>
|
||||
{/if}
|
||||
|
||||
{#if type === 'duration'}
|
||||
<div class="input-group">
|
||||
{#each extraData as f}
|
||||
<div class="time-input">
|
||||
<input type="number" name="{name}[{f}]" id="{fieldId}-{f}"
|
||||
value={durationValue(f)}
|
||||
min="0" max={(f === 's' || f === 'm') && '60' || undefined}
|
||||
{...$$restProps}>
|
||||
<label for="{fieldId}-{f}">{{ f }}</label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === 'select'}
|
||||
<select name={name} id={fieldId} {...$$restProps} on:input={handleInput}>
|
||||
{#each extraData as option}
|
||||
<option value={(option.display === undefined || option.value !== undefined) && (option.value || option)}
|
||||
selected={value === (option.value || option)}>{option.display || option}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<i data-feather="chevron-down"></i>
|
||||
{:else if type === 'textarea'}
|
||||
<textarea {name} id={fieldId} bind:value={value} {...$$restProps}></textarea>
|
||||
{:else if type === 'checkbox'}
|
||||
<input {type} {name} id={fieldId} checked={value === 'on'} {...$$restProps}>
|
||||
{:else}
|
||||
<input {type} {name} id={fieldId} {value} {...$$restProps} on:input={handleInput}>
|
||||
{/if}
|
||||
|
||||
<label for="{fieldId}{type === 'duration' && '-' + extraData[0] || ''}">{@html placeholder || ''}<slot/></label>
|
||||
</div>
|
||||
|
||||
{#if validation}
|
||||
<div class="error"><i data-feather="x-circle"></i> {validation.message}</div>
|
||||
{/if}
|
||||
{#if hint}
|
||||
<div class="hint"><i data-feather="info"></i> {hint}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
39
src/assets/views/utils/Form.svelte
Normal file
39
src/assets/views/utils/Form.svelte
Normal file
@ -0,0 +1,39 @@
|
||||
<script context="module">
|
||||
let nextAvailableFormId = 0;
|
||||
</script>
|
||||
<script lang="ts">
|
||||
import CsrfTokenField from "./CsrfTokenField.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import {setContext} from "svelte";
|
||||
|
||||
export let action: string;
|
||||
export let button: boolean = false;
|
||||
export let submitText: string;
|
||||
export let submitIcon: string;
|
||||
export let submitClass: string = undefined;
|
||||
export let confirm: string = undefined;
|
||||
|
||||
const formId = nextAvailableFormId++;
|
||||
setContext('formId', formId);
|
||||
|
||||
function handleSubmit(e) {
|
||||
if (confirm && !window.confirm(confirm)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form {action} method="POST" id="{formId}-form" on:submit={handleSubmit}>
|
||||
<CsrfTokenField/>
|
||||
<slot/>
|
||||
<button type="submit" class={submitClass}>
|
||||
{#if submitIcon}
|
||||
<Icon name={submitIcon}/>
|
||||
{/if}
|
||||
{#if button}
|
||||
<span class="tip">{submitText}</span>
|
||||
{:else}
|
||||
{submitText}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
11
src/assets/views/utils/Icon.svelte
Normal file
11
src/assets/views/utils/Icon.svelte
Normal file
@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
</script>
|
||||
|
||||
{#if name}
|
||||
{#if name.startsWith('fa') }
|
||||
<i class="{name} feather icon"></i>
|
||||
{:else}
|
||||
<i data-feather="{name}" class="icon"></i>
|
||||
{/if}
|
||||
{/if}
|
Loading…
Reference in New Issue
Block a user