Convert all views to svelte

This commit is contained in:
Alice Gaudon 2021-06-01 16:14:24 +02:00
parent 64cec2987d
commit 9ac42bb3db
27 changed files with 795 additions and 305 deletions

View 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);
}
};
}
}

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

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

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

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

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

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

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

View 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}

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

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

View File

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

View File

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

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

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

View 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}/>

View 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}

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

View 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}