Start of many new pages
All checks were successful
ci / docker_image (push) Successful in 1m28s
ci / deploy (push) Successful in 17s

This commit is contained in:
Drake Marino 2025-01-21 21:49:33 -06:00
parent 95fb591b4b
commit 76c2680c60
21 changed files with 509 additions and 75 deletions

View File

@ -4,7 +4,8 @@
|------------------|------------------------| |------------------|------------------------|
| `00000001` | View postings | | `00000001` | View postings |
| `00000010` | View account | | `00000010` | View account |
| `00000100` | Submit postings access | | `00000100` | Apply for jobs |
| `00001000` | Manage postings | | `00001000` | Submit postings |
| `00010000` | Manage users | | `00010000` | Manage tags |
| `00100000` | Apply | | `00100000` | Manage postings |
| `01000000` | Manage users |

View File

@ -2,22 +2,29 @@
@import 'tailwindcss/components'; @import 'tailwindcss/components';
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
[data-theme='light'] { [data-theme='light'] {
--text-color: #000000; --text-color: #000000;
--bg-color: #f4f4f4; --bg-color: #f4f4f4;
--hover-bg-color: #e4e4f0; --hover-bg-color: #e4e4f0;
--elevated-bg-color: #e0e0e0; --separator-line-color: #e0e0e0;
--low-emphasis-text-color: #6b6b6b; --low-emphasis-text-color: #6b6b6b;
--primary-color: #1F96F3; --primary-color: #1F96F3;
--dull-primary-color: #51aaf0;
--elevated-bg-color: #ffffff;
--bg-accent-color: #f4f4f4;
} }
[data-theme='dark'] { [data-theme='dark'] {
--text-color: #f4f4f4; --text-color: #f4f4f4;
--bg-color: #080808; --bg-color: #080808;
--hover-bg-color: #1f2937; --hover-bg-color: #1f2937;
--elevated-bg-color: #1a202c; --separator-line-color: #1a2029;
--low-emphasis-text-color: #999999; --low-emphasis-text-color: #999999;
--primary-color: #1F96F3; --primary-color: #1F96F3;
--dull-primary-color: #1569ab;
--elevated-bg-color: #0c0c0d;
--bg-accent-color: #202020;
} }
body { body {
@ -31,7 +38,7 @@ h1 {
} }
.elevated { .elevated {
background-color: var(--elevated-bg-color); background-color: var(--separator-line-color);
@apply shadow-lg @apply shadow-lg
} }
@ -52,11 +59,11 @@ h1 {
} }
.top-border { .top-border {
border-top: 1px solid var(--elevated-bg-color); border-top: 1px solid var(--separator-line-color);
} }
.bottom-border { .bottom-border {
border-bottom: 1px solid var(--elevated-bg-color); border-bottom: 1px solid var(--separator-line-color);
} }
.icon-16 { .icon-16 {
@ -77,18 +84,27 @@ h1 {
'opsz' 20 'opsz' 20
} }
input[type='search'] { input[type='search'], input[type='text'], input[type='password'] {
background-color: var(--bg-color); background-color: var(--bg-color);
color: var(--text-color); color: var(--text-color);
border: 1px solid var(--elevated-bg-color); border: 1px solid var(--separator-line-color);
} }
input[type='search']:focus { input[type='search']:focus, input[type='text']:focus, input[type='password']:focus{
outline: 0 solid var(--text-color); outline: 0 solid var(--text-color);
border: 1px solid var(--primary-color); border: 1px solid var(--primary-color);
box-shadow: 0 0 0 0 var(--primary-color); box-shadow: 0 0 0 0 var(--primary-color);
} }
.input-field {
border: 1px solid var(--separator-line-color);
border-radius: 4px;
}
.arrange-vertically > * {
display: block;
}
.search-bar { .search-bar {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
@ -97,7 +113,7 @@ input[type='search']:focus {
.search-bar input[type='search'] { .search-bar input[type='search'] {
flex-grow: 1; flex-grow: 1;
border: 1px solid var(--elevated-bg-color); border: 1px solid var(--separator-line-color);
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
outline: none; outline: none;
@ -107,9 +123,9 @@ input[type='search']:focus {
.search-bar button { .search-bar button {
height: 36px; height: 36px;
background: none; background: none;
border-top: 1px solid var(--elevated-bg-color); border-top: 1px solid var(--separator-line-color);
border-right: 1px solid var(--elevated-bg-color); border-right: 1px solid var(--separator-line-color);
border-bottom: 1px solid var(--elevated-bg-color); border-bottom: 1px solid var(--separator-line-color);
border-top-right-radius: 4px; border-top-right-radius: 4px;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
cursor: pointer; cursor: pointer;
@ -123,15 +139,62 @@ input[type='search']:focus {
} }
.container { .base-container {
width: 100%; width: 100%;
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
} }
.signin-container {
width: 100%;
max-width: 420px;
margin: 0 auto;
}
.content { .content {
width: 100%; width: 100%;
@apply px-6; @apply px-6;
} }
.search-cancel::-webkit-search-cancel-button {
-webkit-appearance: none;
background-color: var(--text-color);
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23777'><path d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/></svg>");
background-size: 20px 20px;
height: 20px;
width: 20px;
}
table, .table {
/*border-collapse: separate;*/
width: 100%;
/*text-align: start;*/
}
tr {
border-top: 1px solid var(--separator-line-color);
text-align: left;
}
th.left, td.left {
padding-left: 0.5rem;
}
.borders {
border: 1px solid var(--separator-line-color);
}
.elevated {
background-color: var(--elevated-bg-color);
@apply shadow-lg
}
.primary-bg-color {
background-color: var(--primary-color);
}
.dull-primary-bg-color {
background-color: var(--dull-primary-color);
}

9
src/lib/consts.ts Normal file
View File

@ -0,0 +1,9 @@
export const PERMISSIONS = {
VIEW_POSTINGS: 0b00000001,
VIEW_ACCOUNT: 0b00000010,
APPLY_FOR_JOBS: 0b00000100,
SUBMIT_POSTINGS: 0b00001000,
MANAGE_TAGS: 0b00010000,
MANAGE_POSTINGS: 0b00100000,
MANAGE_USERS: 0b01000000
};

View File

@ -1,12 +1,17 @@
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import sql from '$lib/db/db.server'; import sql from '$lib/db/db.server';
import type { Cookies } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
export async function createUser(username: string, password: string): Promise<void> { export async function createUser(username: string, password: string): Promise<void> {
const password_hash: string = await bcrypt.hash(password, 12); const password_hash: string = await bcrypt.hash(password, 12);
const timestamp = new Date(Date.now()).toISOString();
console.log(timestamp);
const response = await sql` const response = await sql`
INSERT INTO users (username, password_hash, perms) INSERT INTO users (username, password_hash, perms, created_at, last_signin, active)
VALUES (${username}, ${password_hash}, 3); VALUES (${username}, ${password_hash}, 3, ${timestamp}, ${timestamp}, ${true});
`; `;
} }
@ -25,3 +30,71 @@ export async function checkUserCreds(username: string, password: string): Promis
} }
return -1; return -1;
} }
export function getUserPerms(cookies: Cookies): number {
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const JWT = cookies.get('jwt');
if (JWT) {
try {
const decoded = jwt.verify(JWT, process.env.JWT_SECRET);
if (typeof decoded === 'object' && 'perms' in decoded) {
return decoded['perms'];
}
} catch (err) {
return -1;
}
}
return -1;
}
// should require MANAGE_USERS permission
export async function getUsers(): Promise<User[]> {
const users = await sql<
{
id: number;
username: string;
perms: number;
created_at: Date;
last_signin: Date;
active: boolean;
}[]
>`
SELECT id, username, perms,
created_at AT TIME ZONE 'UTC' AS created_at,
last_signin AT TIME ZONE 'UTC' AS last_signin,
active
FROM users;
`;
return users.map(
(user): User => ({
id: user.id,
username: user.username,
perms: user.perms,
created_at: user.created_at,
last_signin: user.last_signin,
active: user.active
})
);
}
// should require MANAGE_TAGS permission
export async function getTags(): Promise<Tag[]> {
const tags = await sql<
{
id: number;
display_name: string;
}[]
>`
SELECT id, display_name
FROM tags;
`;
return tags.map(
(tag): Tag => ({
id: tag.id,
display_name: tag.display_name
})
);
}

13
src/lib/types.ts Normal file
View File

@ -0,0 +1,13 @@
interface User {
id: number | null;
username: string;
perms: number;
created_at: Date | null;
last_signin: Date | null;
active: boolean | null;
}
interface Tag {
id: number;
display_name: string;
}

29
src/routes/+error.svelte Normal file
View File

@ -0,0 +1,29 @@
<script>
import { page } from '$app/stores';
</script>
<div style="padding-top: 32px" class="text-center">
<h1 class="text-9xl font-bold">
{$page.status}
</h1>
<h1>Thats an error</h1>
{#if $page.status === 404}
<p>We cant seem to find the page you are looking for.</p>
<p>The address may be mistyped, or the page may have moved or been deleted.</p>
{/if}
{#if $page.status === 403}
<p>You dont have access to this page!</p>
<p>Please contact your admin if you think this is a mistake</p>
{/if}
{#if $page.status === 401}
<p>You must be signed-in to view this page!</p>
{/if}
{#if $page.status === 500}
<p>This one is on our end...</p>
<p>We are working to resolve this as fast as possible.</p>
{/if}
{#if $page.status !== 404 && $page.status !== 403 && $page.status !== 401 && $page.status !== 500}
<p>An unexpected error has occurred.</p>
<p>Please try again later</p>
{/if}
</div>

View File

@ -2,7 +2,7 @@
import '../app.css'; import '../app.css';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { userState } from '$lib/shared.svelte'; import { userState } from '$lib/shared.svelte';
// import { userState } from '$lib/shared.svelte'; import { PERMISSIONS } from '$lib/consts';
let currentTheme: string = $state(''); let currentTheme: string = $state('');
@ -42,7 +42,7 @@
<link <link
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..40,400,0,0&display=block&icon_names=account_circle,arrow_drop_down,dark_mode,group,light_mode,login,search,sell,work" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..40,400,0,0&display=block&icon_names=account_circle,arrow_drop_down,check,close,dark_mode,edit,group,info,light_mode,login,person,search,sell,visibility,visibility_off,work"
/> />
<div class="bottom-border flex h-14 justify-between p-3 align-middle"> <div class="bottom-border flex h-14 justify-between p-3 align-middle">
@ -57,34 +57,28 @@
/> />
</a> </a>
<a href="/about" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">About</a> <a href="/about" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">About</a>
{#if (userState.perms & 0b00000001) !== 0} {#if (userState.perms & PERMISSIONS.VIEW_POSTINGS) !== 0}
<a href="/listings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Listings</a> <a href="/listings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Listings</a>
{/if} {/if}
{#if (userState.perms & 0b00001000) !== 0} {#if (userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0}
<a href="/administration/postings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm" <a href="/admin/postings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm"
>Administration</a >Administration</a
> >
{/if} {/if}
</nav> </nav>
<div> <div>
<button onclick={toggleTheme} class=""> <button onclick={toggleTheme} class="">
<span class="material-symbols-outlined hover-bg-color rounded-full p-1 dark:invisible"> <span class="material-symbols-outlined hover-bg-color rounded-full p-1">
{'light_mode'} {currentTheme === 'light' ? 'light_mode' : 'dark_mode'}
</span>
</button>
<button onclick={toggleTheme} class="">
<span
class="material-symbols-outlined hover-bg-color invisible rounded-full p-1 dark:visible"
>
{'dark_mode'}
</span> </span>
</button> </button>
<button <button
onclick={() => onclick={() =>
(window.location.href = (userState.perms & 0b00000010) !== 0 ? '/account' : '/signin')} (window.location.href =
(userState.perms & PERMISSIONS.VIEW_ACCOUNT) !== 0 ? '/account' : '/signin')}
> >
<span class="material-symbols-outlined hover-bg-color rounded-full p-1"> <span class="material-symbols-outlined hover-bg-color rounded-full p-1">
{(userState.perms & 0b00000010) !== 0 ? 'account_circle' : 'login'} {(userState.perms & PERMISSIONS.VIEW_ACCOUNT) !== 0 ? 'account_circle' : 'login'}
</span> </span>
</button> </button>
</div> </div>

View File

@ -0,0 +1,9 @@
<script>
import { onMount } from 'svelte';
onMount(() => {
if (!document.cookie.includes('jwt=')) {
window.location.href = '/signin';
}
});
</script>

View File

@ -7,28 +7,27 @@
<div class="bottom-border h-10 pt-2 text-center"> <div class="bottom-border h-10 pt-2 text-center">
<a <a
href="/administration/postings" href="/admin/postings"
class="p-2 {$page.url.pathname === '/administration/postings' class="p-2 {$page.url.pathname === '/admin/postings'
? 'primary-underline font-bold' ? 'primary-underline font-bold'
: 'low-emphasis-text'}" : 'low-emphasis-text'}"
><span class="material-symbols-outlined align-bottom">work</span> Postings</a ><span class="material-symbols-outlined align-bottom">work</span> Postings</a
> >
<a <a
href="/administration/users" href="/admin/users"
class="p-2 {$page.url.pathname === '/administration/users' class="p-2 {$page.url.pathname === '/admin/users'
? 'primary-underline font-bold' ? 'primary-underline font-bold'
: 'low-emphasis-text'}" : 'low-emphasis-text'}"
><span class="material-symbols-outlined align-bottom">group</span> Users</a ><span class="material-symbols-outlined align-bottom">group</span> Users</a
> ><a
<a href="/admin/tags"
href="/administration/tags" class="{$page.url.pathname === '/admin/tags'
class="{$page.url.pathname === '/administration/tags'
? 'primary-underline font-bold' ? 'primary-underline font-bold'
: 'low-emphasis-text'} p-2" : 'low-emphasis-text'} p-2"
><span class="material-symbols-outlined align-bottom">sell</span> Tags</a ><span class="material-symbols-outlined align-bottom">sell</span> Tags</a
> >
</div> </div>
<div class="container"> <div class="base-container">
{@render children()} {@render children()}
</div> </div>

View File

@ -0,0 +1,7 @@
<script>
import { onMount } from 'svelte';
onMount(() => {
window.location.href = '/admin/postings';
});
</script>

View File

@ -0,0 +1,14 @@
import type { PageServerLoad } from './$types';
import { getUserPerms, getTags } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ cookies }) => {
const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_TAGS) > 0) {
return {
tags: await getTags()
};
}
error(401, 'Unauthorized');
};

View File

@ -0,0 +1,69 @@
<script lang="ts">
let { data } = $props();
</script>
<div class="content">
<div class="elevated borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">
Tag Management (Total: {data.tags?.length || 0})
</div>
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/admin/tags/create"
>Create New Tag</a
>
</div>
<form action="" class="px-4">
<div class="flex py-4">
<div class="search-bar">
<input
type="search"
name="searchTags"
id="searchTags"
placeholder="Search Tags"
class="search-cancel"
/>
<button><span class="material-symbols-outlined">search</span></button>
</div>
<button class="hover-bg-color mx-2 rounded py-2 pl-3 pr-2 text-sm"
>Filter<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span
></button
>
<button class="hover-bg-color rounded py-2 pl-3 pr-2 text-sm"
>Sort<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span
></button
>
</div>
</form>
<div class="table">
<table>
<thead>
<tr>
<th class="left py-1">ID</th>
<th class="py-1">Name</th>
<th class="py-1"></th>
</tr>
</thead>
<tbody>
{#if data.tags !== undefined}
{#each data.tags as tag}
<tr>
<td class="left">{tag.id}</td>
<td>{tag.display_name}</td>
<td class="w-28 pr-1 text-end">
<a
class="hover-bg-color material-symbols-outlined icon-20 my-1 rounded p-1"
href="/admin/tags/{tag.id}">info</a
>
<a
class="hover-bg-color material-symbols-outlined icon-20 my-1 ml-1 mr-8 rounded p-1"
href="/admin/tags/{tag.id}/edit">edit</a
>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,14 @@
import type { PageServerLoad } from './$types';
import { getUserPerms, getUsers } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ cookies }) => {
const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0) {
return {
users: await getUsers()
};
}
error(401, 'Unauthorized');
};

View File

@ -0,0 +1,92 @@
<script lang="ts">
let { data } = $props();
const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
// console.log(data);
</script>
<div class="content">
<div class="elevated borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">
User Account Management (Total: {data.users?.length || 0})
</div>
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/admin/users/create"
>Create New User</a
>
</div>
<form action="" class="px-4">
<div class="flex py-4">
<div class="search-bar">
<input
type="search"
name="searchUsers"
id="searchUsers"
placeholder="Search Users"
class="search-cancel"
/>
<button><span class="material-symbols-outlined">search</span></button>
</div>
<button class="hover-bg-color mx-2 rounded py-2 pl-3 pr-2 text-sm"
>Filter<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span
></button
>
<button class="hover-bg-color rounded py-2 pl-3 pr-2 text-sm"
>Sort<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span
></button
>
</div>
</form>
<div class="table">
<table>
<thead>
<tr>
<th class="left py-1">ID</th>
<th class="py-1">Username</th>
<th class="py-1">Permissions</th>
<th class="py-1">Created</th>
<th class="py-1">Last Sign-In</th>
<th class="py-1">Active</th>
<th class="py-1"></th>
</tr>
</thead>
<tbody>
{#if data.users !== undefined}
{#each data.users as user}
<tr>
<td class="left">{user.id}</td>
<td>{user.username}</td>
<td>{user.perms}</td>
<td
>{user.created_at?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>
<td
>{user.last_signin?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>
<td class="material-symbols-outlined py-2">
{user.active ? 'check' : 'close'}
</td>
<td class="w-28 pr-1 text-end">
<a
class="hover-bg-color material-symbols-outlined icon-20 my-1 rounded p-1"
href="/admin/users/{user.id}">person</a
>
<a
class="hover-bg-color material-symbols-outlined icon-20 my-1 ml-1 mr-8 rounded p-1"
href="/admin/users/{user.id}/edit">edit</a
>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>

View File

@ -1,18 +0,0 @@
<div class="content">
<form action="">
<div class="flex py-4">
<div class="search-bar">
<input type="search" name="searchUsers" id="searchUsers" placeholder="Search Users" />
<button><span class="material-symbols-outlined">search</span></button>
</div>
<button class="hover-bg-color mx-2 mr-1 rounded px-3 py-2 text-sm"
>Filter<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span
></button
>
<button class="hover-bg-color mr-1 rounded px-3 py-2 text-sm"
>Sort<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span
></button
>
</div>
</form>
</div>

View File

View File

@ -38,7 +38,7 @@ export const actions: Actions = {
} }
}, },
login: async ({ request, cookies }) => { signin: async ({ request, cookies }) => {
const data = await request.formData(); const data = await request.formData();
const username = data.get('username')?.toString(); const username = data.get('username')?.toString();
const password = data.get('password')?.toString(); const password = data.get('password')?.toString();

View File

@ -1,22 +1,88 @@
<script lang="ts"> <script lang="ts">
import type { ActionData } from './$types'; import type { ActionData } from './$types';
import { onMount } from 'svelte';
onMount(() => {
if (document.cookie.includes('jwt=')) {
window.location.href = '/account';
}
});
let passwordVisible = $state(false);
function showPassword() {
const password = document.querySelector('input[name="password"]');
if (password) {
if (password.getAttribute('type') === 'password') {
password.setAttribute('type', 'text');
passwordVisible = true;
} else {
password.setAttribute('type', 'password');
passwordVisible = false;
}
}
}
// receive form data from server // receive form data from server
let form: ActionData; let form: ActionData;
</script> </script>
<div class="container"> <div class="signin-container place-items-center pt-8">
<h1 class="is-size-3 has-text-weight-semibold my-4">Sign In or Register</h1> <div class="elevated content rounded-md p-8">
<form method="POST"> <h1 class="is-size-3 has-text-weight-semibold my-4">Welcome Back!</h1>
<input class="input my-2" type="text" placeholder="Username" name="username" required /> <form method="POST" class="arrange-vertically">
<input class="input my-2" type="password" placeholder="Password" name="password" required /> <input
class="input-field my-2 w-full"
type="text"
placeholder="Username"
name="username"
required
/>
<div class="relative w-full">
<input
type={passwordVisible ? 'text' : 'password'}
class="input-field my-2 w-full pr-10"
placeholder="Password"
name="password"
required
/>
<button
type="button"
onclick={showPassword}
class="absolute right-2.5 top-8 -translate-y-1/2 transform"
>
<span class="material-symbols-outlined"
>{passwordVisible ? 'visibility' : 'visibility_off'}</span
>
</button>
</div>
<!-- display error message --> <!-- TODO: fix -->
{#if form?.errorMessage} {#if form?.errorMessage}
<div class="has-text-danger my-2">{form.errorMessage}</div> <div class="has-text-danger my-2">{form.errorMessage}</div>
{/if} {/if}
<button class="button mr-3 mt-4" type="submit" formaction="?/register">Register</button> <button
<button class="button is-primary mt-4" type="submit" formaction="?/login">Sign In</button> class="primary-bg-color mt-6 w-full rounded px-2 py-2"
</form> type="submit"
formaction="?/signin">Sign In</button
>
<a href="/register" class="low-emphasis-text mt-2">Don't have an account? Register here.</a>
</form>
</div>
</div> </div>
<!--<div class="container">-->
<!-- <h1 class="is-size-3 has-text-weight-semibold my-4">Sign In or Register</h1>-->
<!-- <form method="POST">-->
<!-- <input class="input my-2" type="text" placeholder="Username" name="username" required />-->
<!-- <input class="input my-2" type="password" placeholder="Password" name="password" required />-->
<!-- &lt;!&ndash; display error message &ndash;&gt;-->
<!-- {#if form?.errorMessage}-->
<!-- <div class="has-text-danger my-2">{form.errorMessage}</div>-->
<!-- {/if}-->
<!-- <button class="button mr-3 mt-4" type="submit" formaction="?/register">Register</button>-->
<!-- <button class="button is-primary mt-4" type="submit" formaction="?/login">Sign In</button>-->
<!-- </form>-->
<!--</div>-->