add: frontend profile data etc

This commit is contained in:
$(pass /github/name)
2024-09-10 18:52:32 +02:00
parent f0b2409963
commit b34a85e9d6
21 changed files with 1927 additions and 693 deletions

View File

@@ -3,7 +3,7 @@
import { applyAction, enhance } from "$app/forms"; import { applyAction, enhance } from "$app/forms";
import { page } from "$app/stores"; import { page } from "$app/stores";
// import Developer from "$lib/img/hero-image.png"; // import Developer from "$lib/img/hero-image.png";
import Avatar from "$lib/img/teamavatar.png"; import Avatar from "$lib/img/TeamAvatar.jpeg";
onMount(() => { onMount(() => {
console.log("Page data in Header:", $page); console.log("Page data in Header:", $page);
}); });

View File

@@ -0,0 +1,117 @@
<script>
// @ts-nocheck
export let avatar;
export let fieldName;
export let title;
let newAvatar;
const onFileSelected = (e) => {
const target = e.target;
if (target && target.files) {
let reader = new FileReader();
reader.readAsDataURL(target.files[0]);
reader.onload = (e) => {
newAvatar = e.target?.result;
};
}
};
</script>
<div id="app">
{#if avatar}
<img class="avatar" src={avatar} alt="d" />
{:else}
<img
class="avatar"
src={newAvatar
? newAvatar
: "https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png"}
alt=""
/>
<input
type="file"
id="file"
name={fieldName}
required
on:change={(e) => onFileSelected(e)}
/>
<label for="file" class="btn-3">
{#if newAvatar}
<span>Bild ausgewählt, clicke Hochladen.</span>
{:else}
<span>{title}</span>
{/if}
</label>
{/if}
</div>
<style>
#app {
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
color: rgb(148 163 184);
}
.avatar {
display: flex;
width: 8rem;
}
[type="file"] {
height: 0;
overflow: hidden;
width: 0;
}
[type="file"] + label {
background: #9b9b9b;
border: none;
border-radius: 5px;
color: #fff;
cursor: pointer;
display: inline-block;
font-weight: 500;
margin-bottom: 1rem;
outline: none;
padding: 1rem 50px;
position: relative;
transition: all 0.3s;
vertical-align: middle;
}
[type="file"] + label:hover {
background-color: #9b9b9b;
}
[type="file"] + label.btn-3 {
background-color: #d43aff;
border-radius: 0;
overflow: hidden;
}
[type="file"] + label.btn-3 span {
display: inline-block;
height: 100%;
transition: all 0.3s;
width: 100%;
}
[type="file"] + label.btn-3::before {
color: #fff;
content: "\01F4F7";
font-size: 200%;
height: 100%;
left: 45%;
position: absolute;
top: -180%;
transition: all 0.3s;
width: 100%;
}
[type="file"] + label.btn-3:hover {
background-color: rgba(14, 166, 236, 0.5);
}
[type="file"] + label.btn-3:hover span {
transform: translateY(300%);
}
[type="file"] + label.btn-3:hover::before {
top: 0;
}
</style>

View File

@@ -0,0 +1,100 @@
<script>
import { createEventDispatcher } from "svelte";
import { t } from "svelte-i18n";
/** @type {string} */
export let name;
/** @type {string} */
export let type = "text";
/** @type {string} */
export let value = "";
/** @type {string} */
export let placeholder = "";
/** @type {string} */
export let label = "";
const dispatch = createEventDispatcher();
/**
* @param {Event} event - The input event
*/
function handleInput(event) {
const target = event.target;
if (target instanceof HTMLInputElement) {
const inputValue = target.value;
value = inputValue;
// dispatch("input", { name, value: inputValue });
}
}
/**
* Validates the field
* @param {string} name - The name of the field
* @param {string} value - The value of the field
* @returns {string|null} The error message or null if valid
*/
function validateField(name, value) {
switch (name) {
case "first_name":
case "last_name":
return value.trim() ? null : $t("required");
case "email":
return /^\S+@\S+\.\S+$/.test(value) ? null : $t("invalid_email");
case "password":
return !value.trim() || value.length >= 8
? null
: $t("required_password");
// case "password2":
// // Note: This case might need special handling as it depends on another field
// return $t("required_password_match");
default:
return null;
}
}
$: error = validateField(name, value);
</script>
<div class="input-box">
<span class="label">{label}</span>
<div class="input-error-container">
{#if error}
<span class="error-message">{error}</span>
{/if}
<input
{name}
{type}
{placeholder}
{value}
on:input={handleInput}
on:blur={handleInput}
class="input"
/>
</div>
</div>
<style>
.input-error-container {
display: flex;
flex-direction: column;
align-items: flex-end;
width: 100%;
max-width: 444px;
}
.error-message {
color: #eb5424;
font-size: 12px;
margin-bottom: 5px;
align-self: flex-start;
}
.input {
width: 100%;
}
</style>

View File

@@ -0,0 +1,127 @@
<script>
import { quintOut } from "svelte/easing";
import { createEventDispatcher } from "svelte";
const modal = (/** @type {Element} */ node, { duration = 300 } = {}) => {
const transform = getComputedStyle(node).transform;
return {
duration,
easing: quintOut,
css: (/** @type {any} */ t, /** @type {number} */ u) => {
return `transform:
${transform}
scale(${t})
translateY(${u * -100}%)
`;
},
};
};
const dispatch = createEventDispatcher();
function closeModal() {
dispatch("close", {});
}
</script>
<div class="modal-background">
<div
transition:modal={{ duration: 1000 }}
class="modal"
role="dialog"
aria-modal="true"
>
<!-- svelte-ignore a11y-missing-attribute -->
<a
title="Close"
class="modal-close"
on:click={closeModal}
role="button"
tabindex="0"
on:keydown={(e) => e.key == "Enter" && closeModal()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 384 512"
aria-hidden="true"
>
<path
d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"
/>
</svg>
<span class="sr-only">Close modal</span>
</a>
<div class="container">
<slot />
</div>
</div>
</div>
<style>
.modal-background {
width: 100%;
height: 100%;
position: fixed;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 9999;
display: flex;
}
.modal {
position: relative;
left: 50%;
top: 50%;
width: 70%;
box-shadow: 0 0 10px hsl(0 0% 0% / 10%);
transform: translate(-50%, -50%);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
@media (max-width: 990px) {
.modal {
width: 90%;
}
}
.modal-close {
border: none;
}
.modal-close svg {
display: block;
margin-left: auto;
margin-right: auto;
fill: rgb(14 165 233 /1);
transition: all 0.5s;
}
.modal-close:hover svg {
fill: rgb(225 29 72);
transform: scale(1.5);
}
.modal .container {
max-height: 90vh;
overflow-y: auto;
align-items: center;
}
@media (min-width: 680px) {
.modal .container {
flex-direction: column;
left: 0;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,24 @@
<script>
/** @type {number | null} */
export let width;
/** @type {string | null} */
export let message;
</script>
<div class="loading">
<p class="simple-loader" style={width ? `width: ${width}px` : ""} />
{#if message}
<p>{message}</p>
{/if}
</div>
<style>
.loading {
display: flex;
align-items: center;
justify-content: center;
}
.loading p {
margin-left: 0.5rem;
}
</style>

View File

@@ -212,9 +212,7 @@ li strong {
} }
} }
.button-dark { .button-dark {
transition: transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: white; color: white;
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
@@ -223,14 +221,13 @@ li strong {
cursor: pointer; cursor: pointer;
background-color: transparent; background-color: transparent;
border: 1px solid #595b5c; border: 1px solid #595b5c;
margin: 2px;
} }
.button-dark:hover { .button-dark:hover {
border-color: #fff; border-color: #fff;
} }
.button-colorful { .button-colorful {
transition: transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: white; color: white;
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
@@ -245,9 +242,7 @@ li strong {
border-color: #c907ff; border-color: #c907ff;
} }
.button-orange { .button-orange {
transition: transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: white; color: white;
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
@@ -262,9 +257,7 @@ li strong {
border-color: #ca3f12; border-color: #ca3f12;
} }
.button-colorful:disabled { .button-colorful:disabled {
transition: transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: white; color: white;
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
@@ -344,7 +337,6 @@ li strong {
} }
.container .content { .container .content {
max-width: 795px; max-width: 795px;
padding-left: 116px;
} }
.container .content .step-title { .container .content .step-title {
font-size: 36px; font-size: 36px;
@@ -353,6 +345,7 @@ li strong {
.input-box { .input-box {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
margin: 10px 0; margin: 10px 0;
padding: 0 20px; padding: 0 20px;
width: 100%; width: 100%;
@@ -364,8 +357,8 @@ li strong {
font-size: 13px; font-size: 13px;
} }
.input-box .label { .input-box .label {
text-transform: lowercase;
margin: 0 1ch 0 0; margin: 0 1ch 0 0;
font-size: 16px;
} }
.input-box .input { .input-box .input {
background-color: #494848; background-color: #494848;
@@ -379,7 +372,6 @@ li strong {
@media (min-width: 680px) { @media (min-width: 680px) {
.input-box { .input-box {
padding: 0 30px; padding: 0 30px;
margin: 32px 0;
} }
} }
.btn-container { .btn-container {

View File

@@ -12,27 +12,29 @@
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW9.ttf) src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW9.ttf)
format("truetype"); format("truetype");
} }
html { html {
padding: 0 30px; padding: 0 30px;
background-color: #000; background-color: black;
color: #9b9b9b; color: #9b9b9b;
font-family: Quicksand, sans-serif; font-family: "Quicksand", sans-serif;
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: normal;
} }
body { body {
max-width: 1200px; max-width: 1200px;
margin: 5em auto 0 auto; margin: 5em auto 0 auto;
} }
code, pre,
pre { code {
display: inline; display: inline;
font-family: "Roboto Mono", monospace; font-family: "Roboto Mono", monospace;
font-size: 16px; font-size: 16px;
} }
input { input {
font-family: "Roboto Mono", monospace; font-family: "Roboto Mono", monospace;
color: #fff; color: white;
border-style: none; border-style: none;
height: 21px; height: 21px;
font-size: 16px; font-size: 16px;
@@ -47,7 +49,7 @@ h4,
h5, h5,
h6 { h6 {
margin: 0; margin: 0;
font-weight: 400; font-weight: normal;
} }
h2 { h2 {
margin: 0 0 45px 0; margin: 0 0 45px 0;
@@ -106,6 +108,7 @@ li strong {
display: initial; display: initial;
} }
} }
.header { .header {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -114,7 +117,7 @@ li strong {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
padding: 3em 0 0; padding: 3em 0 0;
background: #000; background: black;
} }
.header.top-banner-open { .header.top-banner-open {
margin-top: 5px; margin-top: 5px;
@@ -152,7 +155,7 @@ li strong {
.header .header-container .header-left .header-crafted-by-container .auth0 { .header .header-container .header-left .header-crafted-by-container .auth0 {
margin-left: 1ch; margin-left: 1ch;
color: #fff; color: #fff;
font-weight: 700; font-weight: bold;
} }
.header .header-container .header-right { .header .header-container .header-right {
display: flex; display: flex;
@@ -173,10 +176,12 @@ li strong {
.header .header-container .header-right .header-nav-item.active button { .header .header-container .header-right .header-nav-item.active button {
color: #fff; color: #fff;
} }
.header .header-container .header-right a img { .header .header-container .header-right a img {
margin-top: -0.4rem; margin-top: -0.4rem;
height: 28px; height: 28px;
} }
.header .header-container .header-right .header-nav-item a, .header .header-container .header-right .header-nav-item a,
.header .header-container .header-right .header-nav-item button { .header .header-container .header-right .header-nav-item button {
transition: color 0.3s ease-in-out; transition: color 0.3s ease-in-out;
@@ -207,10 +212,8 @@ li strong {
} }
} }
.button-dark { .button-dark {
transition: transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
border-color 0.3s ease-in-out, color: white;
background-color 0.3s ease-in-out;
color: #fff;
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
padding: 18px 28px; padding: 18px 28px;
@@ -218,15 +221,14 @@ li strong {
cursor: pointer; cursor: pointer;
background-color: transparent; background-color: transparent;
border: 1px solid #595b5c; border: 1px solid #595b5c;
margin: 2px;
} }
.button-dark:hover { .button-dark:hover {
border-color: #fff; border-color: #fff;
} }
.button-colorful { .button-colorful {
transition: transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
border-color 0.3s ease-in-out, color: white;
background-color 0.3s ease-in-out;
color: #fff;
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
padding: 18px 28px; padding: 18px 28px;
@@ -240,10 +242,8 @@ li strong {
border-color: #c907ff; border-color: #c907ff;
} }
.button-orange { .button-orange {
transition: transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
border-color 0.3s ease-in-out, color: white;
background-color 0.3s ease-in-out;
color: #fff;
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
padding: 18px 28px; padding: 18px 28px;
@@ -257,10 +257,8 @@ li strong {
border-color: #ca3f12; border-color: #ca3f12;
} }
.button-colorful:disabled { .button-colorful:disabled {
transition: transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
border-color 0.3s ease-in-out, color: white;
background-color 0.3s ease-in-out;
color: #fff;
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
padding: 18px 28px; padding: 18px 28px;
@@ -297,12 +295,14 @@ li strong {
margin: 0 auto 140px auto; margin: 0 auto 140px auto;
} }
} }
.container { .container {
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
color: #fff; color: white;
letter-spacing: 0; letter-spacing: 0;
opacity: 1; opacity: 1;
} }
.container .content { .container .content {
width: 100%; width: 100%;
flex-grow: 1; flex-grow: 1;
@@ -320,11 +320,13 @@ li strong {
font-size: 16px; font-size: 16px;
font-weight: 300; font-weight: 300;
} }
@media (max-width: 680px) { @media (max-width: 680px) {
.container .content { .container .content {
margin-top: 120px; margin-top: 120px;
} }
} }
@media (min-width: 680px) { @media (min-width: 680px) {
.container { .container {
position: relative; position: relative;
@@ -335,7 +337,6 @@ li strong {
} }
.container .content { .container .content {
max-width: 795px; max-width: 795px;
padding-left: 116px;
} }
.container .content .step-title { .container .content .step-title {
font-size: 36px; font-size: 36px;
@@ -344,6 +345,7 @@ li strong {
.input-box { .input-box {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
margin: 10px 0; margin: 10px 0;
padding: 0 20px; padding: 0 20px;
width: 100%; width: 100%;
@@ -355,13 +357,13 @@ li strong {
font-size: 13px; font-size: 13px;
} }
.input-box .label { .input-box .label {
text-transform: lowercase;
margin: 0 1ch 0 0; margin: 0 1ch 0 0;
font-size: 16px;
} }
.input-box .input { .input-box .input {
background-color: #494848; background-color: #494848;
border-radius: 6px; border-radius: 6px;
outline: 0; outline: none;
border: 3px solid #494848; border: 3px solid #494848;
width: 100%; width: 100%;
max-width: 444px; max-width: 444px;
@@ -370,7 +372,6 @@ li strong {
@media (min-width: 680px) { @media (min-width: 680px) {
.input-box { .input-box {
padding: 0 30px; padding: 0 30px;
margin: 32px 0;
} }
} }
.btn-container { .btn-container {
@@ -404,6 +405,7 @@ li strong {
.warning.hidden { .warning.hidden {
display: none; display: none;
} }
.error { .error {
margin-top: 10rem; margin-top: 10rem;
padding: 30px 40px; padding: 30px 40px;
@@ -424,11 +426,13 @@ li strong {
padding: 65px 80px; padding: 65px 80px;
} }
} }
.footer-branding-container { .footer-branding-container {
color: #fff; color: white;
font-weight: 300; font-weight: 300;
margin-bottom: 73px; margin-bottom: 73px;
} }
.footer-branding-container .footer-branding { .footer-branding-container .footer-branding {
display: flex; display: flex;
width: 400px; width: 400px;
@@ -443,7 +447,7 @@ li strong {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 16px; margin-bottom: 16px;
color: #fff; color: white;
} }
.footer-branding-container .footer-branding .footer-crafted-by-container span { .footer-branding-container .footer-branding .footer-crafted-by-container span {
display: inline-block; display: inline-block;
@@ -455,34 +459,38 @@ li strong {
.footer-branded-crafted-img { .footer-branded-crafted-img {
height: 28px; height: 28px;
} }
.footer-branding-container .footer-branding .footer-copyright { .footer-branding-container .footer-branding .footer-copyright {
color: #696969; color: #696969;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.footer-container { .footer-container {
width: 100%; width: 100%;
color: #fff; color: white;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
@media (min-width: 680px) { @media (min-width: 680px) {
.footer-container { .footer-container {
padding: 0; padding: 0;
} }
} }
.simple-loader { .simple-loader {
--b: 20px; --b: 20px; /* border thickness */
--n: 15; --n: 15; /* number of dashes*/
--g: 7deg; --g: 7deg; /* gap between dashes*/
--c: #d43aff; --c: #d43aff; /* the color */
width: 40px;
width: 40px; /* size */
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 50%; border-radius: 50%;
padding: 1px; padding: 1px; /* get rid of bad outlines */
background: conic-gradient(#0000, var(--c)) content-box; background: conic-gradient(#0000, var(--c)) content-box;
--_m: repeating-conic-gradient( --_m: /* we use +/-1deg between colors to avoid jagged edges */ repeating-conic-gradient(
#0000 0deg, #0000 0deg,
#000 1deg calc(360deg / var(--n) - var(--g) - 1deg), #000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
#0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n)) #0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -0,0 +1,50 @@
export default {
userStatus: {
1: "Nicht verifiziert",
2: "Verifiziert",
3: "Aktiv",
4: "Passiv",
5: "Deaktiviert",
},
userRole: {
0: "Mitglied",
1: "Betrachter",
4: "Bearbeiter",
8: "Administrator",
},
unknown: "Unbekannt",
notes: "Notizen",
address: "Straße & Hausnummer",
city: "Wohnort",
zip_code: "PLZ",
forgot_password: "Passwort vergessen?",
password: "Passwort",
password_repeat: "Passwort wiederholen",
placeholder_password: "Passwort eingeben...",
email: "Email",
placeholder_email: "Emailadresse eingeben...",
login: "Anmelden",
user: "Nutzer",
user_login: "Nutzer Anmeldung",
user_edit: "Nutzer bearbeiten",
profile: "Profil",
membership: "Mitgliedschaft",
bankaccount: "Kontodaten",
status: "Status",
start: "Beginn",
end: "Ende",
parent_member_id: "Hauptmitgliedsnr.",
placeholder_parent_member_id: "Mitgliedsnr des Hauptmitglieds eingeben...",
bank_account_holder: "Kontoinhaber",
bank_name: "Name der Bank",
iban: "IBAN",
bic: "BIC",
mandate_reference: "SEPA Mandat",
placeholder_bank_account_holder: "Namen eingeben...",
placeholder_iban: "IBAN eingeben..",
placeholder_bic: "BIC eingeben(optional)...",
placeholder_mandate_reference: "SEPA Mandatsreferenz eingeben..",
required: "Eingabe benötigt",
required_password: "Password zu kurz, mindestens 8 Zeichen",
required_password_match: "Passwörter stimmen nicht überein!",
};

View File

@@ -0,0 +1,15 @@
export default {
userStatus: {
1: "Unverified",
2: "Verified",
3: "Active",
4: "Passive",
5: "Disabled",
},
userRole: {
0: "Member",
1: "Viewer",
4: "Editor",
8: "Admin",
},
};

View File

@@ -0,0 +1,20 @@
import { register, init, getLocaleFromNavigator, locale } from "svelte-i18n";
function setupI18n() {
register("en", () => import("../locales/en.js"));
register("de", () => import("../locales/de.js"));
init({
fallbackLocale: "de",
initialLocale: getLocaleFromNavigator(),
});
}
setupI18n();
/**
* @param {string} newLocale - The new locale to set
*/
export const changeLocale = (newLocale) => {
locale.set(newLocale);
};

View File

@@ -4,14 +4,27 @@
import Transition from "$lib/components/Transition.svelte"; import Transition from "$lib/components/Transition.svelte";
import "$lib/css/styles.min.css"; import "$lib/css/styles.min.css";
import { waitLocale } from "svelte-i18n";
import { onMount } from "svelte";
import "$lib/utils/i18n.js";
/** @type {import('./$types').PageData} */ /** @type {import('./$types').PageData} */
export let data; export let data;
let ready = false;
onMount(async () => {
await waitLocale();
ready = true;
});
</script> </script>
<Transition key={data.url} duration={600}> {#if ready}
<Transition key={data.url} duration={600}>
<Header /> <Header />
<slot /> <slot />
<Footer /> <Footer />
</Transition> </Transition>
{/if}

View File

@@ -4,16 +4,10 @@
<div class="hero-container"> <div class="hero-container">
<!-- <div class="hero-logo"><img src={Developer} alt="Alexander Stölting" /></div> --> <!-- <div class="hero-logo"><img src={Developer} alt="Alexander Stölting" /></div> -->
<h3 class="hero-subtitle subtitle"> <h3 class="hero-subtitle subtitle">Backend vom Carsharing Zeug</h3>
This application is the demonstration of a series of tutorials on
session-based authentication using Go at the backend and JavaScript
(SvelteKit) on the front-end.
</h3>
<div class="hero-buttons-container"> <div class="hero-buttons-container">
<a <a class="button-dark" href="https://tiny-bits.net/" data-learn-more
class="button-dark" >Auf zu Tiny Bits</a
href="https://dev.to/sirneij/series/23239"
data-learn-more>Learn more</a
> >
</div> </div>
</div> </div>

View File

@@ -0,0 +1,131 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { formatError } from "$lib/utils/helpers";
import { fail, redirect } from "@sveltejs/kit";
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals, params }) {
// redirect user if not logged in
if (!locals.user) {
throw redirect(302, `/auth/login?next=/auth/about/${params.id}`);
}
}
/** @type {import('./$types').Actions} */
export const actions = {
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
updateUser: async ({ request, fetch, cookies, locals }) => {
const formData = await request.formData();
const firstName = String(formData.get("first_name"));
const lastName = String(formData.get("last_name"));
const phone = String(formData.get("phone"));
const birthDate = String(formData.get("birth_date"));
const apiURL = `${BASE_API_URI}/backend/users/update/`;
const res = await fetch(apiURL, {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
Cookie: `jwt=${cookies.get("jwt")}`,
},
body: JSON.stringify({
first_name: firstName,
last_name: lastName,
phone: phone,
birth_date: birthDate,
}),
});
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.error);
return fail(400, { errors: errors });
}
const response = await res.json();
locals.user = response;
if (locals.user.date_of_birth) {
locals.user.date_of_birth = response["date_of_birth"].split("T")[0];
}
throw redirect(303, `/auth/about/${response.id}`);
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
uploadImage: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
/** @type {RequestInit} */
const requestInitOptions = {
method: "POST",
headers: {
Cookie: `jwt=${cookies.get("jwt")}`,
},
body: formData,
};
const res = await fetch(`${BASE_API_URI}/file/upload/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.error);
return fail(400, { errors: errors });
}
const response = await res.json();
return {
success: true,
thumbnail: response["s3_url"],
};
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
deleteImage: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
/** @type {RequestInit} */
const requestInitOptions = {
method: "DELETE",
headers: {
Cookie: `jwt=${cookies.get("jwt")}`,
},
body: formData,
};
const res = await fetch(`${BASE_API_URI}/file/delete/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.error);
return fail(400, { errors: errors });
}
return {
success: true,
thumbnail: "",
};
},
};

View File

@@ -0,0 +1,625 @@
<script>
import ImageInput from "$lib/components/ImageInput.svelte";
import InputField from "$lib/components/InputField.svelte";
import SmallLoader from "$lib/components/SmallLoader.svelte";
import Modal from "$lib/components/Modal.svelte";
import Avatar from "$lib/img/TeamAvatar.jpeg";
import { onMount } from "svelte";
import { applyAction, enhance } from "$app/forms";
import { page } from "$app/stores";
import { receive, send } from "$lib/utils/helpers";
import { t } from "svelte-i18n";
$: ({ user } = $page.data);
/** @typedef {{name: string, src: string}} Avatar */
const avatarFiles = import.meta.glob("$lib/img/Avatar-*.jpeg", {
eager: true,
});
/** @type{Avatar[]} */
let avatars = [];
/**
* @typedef {Object} FormData
* @property {string} first_name
* @property {string} last_name
* @property {string} email
* @property {string} [password]
* @property {string} [password2]
* @property {string} [phone]
* @property {string} address
* @property {string} zip_code
* @property {string} city
*/
/**
* @typedef {Object.<string, string>} ValidationErrors
*/
/**
* @type {ValidationErrors}
*/
let validationErrors = {};
const TABS = ["profile", "membership", "bankaccount"];
let activeTab = TABS[0];
let showModal = false,
isUploading = false,
isUpdating = false,
showAvatars = false;
const open = () => (showModal = true);
const close = () => (showModal = false);
const toggleAvatars = () => (showAvatars = !showAvatars);
onMount(() => {
avatars = Object.entries(avatarFiles).map(([path, module]) => {
if (typeof path !== "string") {
throw new Error("Unexpected non-string path");
}
if (
typeof module !== "object" ||
module === null ||
!("default" in module)
) {
throw new Error("Unexpected module format");
}
const src = module.default;
if (typeof src !== "string") {
throw new Error("Unexpected default export type");
}
return {
name: path.split("/").pop()?.split(".")[0] ?? "Unknown",
src: src,
};
});
});
/** @type {import('./$types').ActionData} */
export let form;
/**
* Sets the active tab
* @param {string} tab - The tab to set as active
*/
function setActiveTab(tab) {
activeTab = tab;
}
/**
* Validates thek form data
* @param {Object} data - The form data to validate
* @returns {ValidationErrors} An object containing validation errors
*/
function validateForm(data) {
/** @type {ValidationErrors} */
let errors = {};
if ("first_name" in data && !String(data.first_name).trim()) {
errors.first_name = $t("required");
}
if ("last_name" in data && !String(data.last_name).trim()) {
errors.last_name = $t("required");
}
if (
"email" in data &&
(!data.email || !/^\S+@\S+\.\S+$/.test(String(data.email)))
) {
errors.email = $t("required");
}
if ("password" in data) {
if (String(data.password).length < 8) {
errors.password = $t("required_password");
}
if ("password2" in data && data.password !== data.password2) {
errors.password2 = $t("required_password_match");
}
}
return errors;
}
/** @type {import('./$types').SubmitFunction} */
const handleUpdate = async ({ form, formData, action, cancel }) => {
/** @type {Object.<string, FormDataEntryValue>} */
const fd = Object.fromEntries(formData);
validationErrors = validateForm(fd);
if (Object.keys(validationErrors).length > 0) {
cancel();
return;
}
isUpdating = true;
return async ({ result }) => {
isUpdating = false;
if (result.type === "success" || result.type === "redirect") {
validationErrors = {};
close();
} else if (result.type == "failure" && result.data?.errors) {
/** @type {ValidationErrors} */
validationErrors = {};
// Assuming result.data.errors is an array of {error: string, id: string}
result.data.errors.forEach(({ error, id }) => {
validationErrors[id] = error;
});
}
await applyAction(result);
};
};
/** @type {import('./$types').SubmitFunction} */
const handleUpload = async () => {
isUploading = true;
return async ({ result }) => {
isUploading = false;
/** @type {any} */
const res = result;
if (result.type === "success" || result.type === "redirect") {
user.thumbnail = res.data.thumbnail;
}
await applyAction(result);
};
};
</script>
<div class="hero-container">
<div class="hero-logo">
<img
src={user.thumbnail ? user.thumbnail : Avatar}
alt={`${user.first_name} ${user.last_name}`}
width="200"
/>
</div>
<div class="user-info">
{#if user.status}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Status:</span>
<span class="value block-value">
<span
>{$t(`userStatus.${user.status}`, {
default: "unknown status",
})}</span
>
<span
>{$t(`userRole.${user.role_id}`, { default: "unknown role" })}</span
>
</span>
</h3>
{/if}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Name:</span>
<span class="value">{`${user.first_name} ${user.last_name}`}</span>
</h3>
{#if user.email}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Email:</span>
<span class="value">{user.email}</span>
</h3>
{/if}
{#if user.address}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Adresse:</span>
<span class="value block-value">
<span>{user.address}</span>
<span>{`${user.zip_code} ${user.city}`}</span>
</span>
</h3>
{/if}
{#if user.phone}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Telefon:</span>
<span class="value">{user.phone}</span>
</h3>
{/if}
{#if user.birth_date}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Geburtstag:</span>
<span class="value">{user.birth_date}</span>
</h3>
{/if}
{#if user.notes}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">{$t("notes")}:</span>
<span class="value">{user.notes}</span>
</h3>
{/if}
</div>
<div class="hero-buttons-container">
<button class="button-dark" on:click={open}>Ändern</button>
</div>
</div>
{#if showModal}
<Modal on:close={close}>
<div class="avatar-container">
<form
class="avatar-form"
action="?/uploadImage"
method="post"
enctype="multipart/form-data"
use:enhance={handleUpload}
>
<div class="current-avatar">
<ImageInput
avatar={user.thumbnail}
fieldName="thumbnail"
title="Nutzerbild auswählen"
/>
</div>
<div class="avatar-buttons">
{#if !user.thumbnail}
{#if isUploading}
<SmallLoader width={30} message={"Uploading..."} />
{:else}
<button class="button-dark" type="submit">Bild hochladen</button>
{/if}
{:else}
<input
type="hidden"
hidden
name="thumbnail_url"
value={user.thumbnail}
required
/>
{#if isUploading}
<SmallLoader width={30} message={"Lösche..."} />
{:else}
<button
class="button-dark"
formaction="?/deleteImage"
type="submit"
>
Bild löschen
</button>
{/if}
{/if}
</div>
</form>
<div class="avatar-buttons">
<button class="button-dark" on:click={toggleAvatars}>
{showAvatars ? "Abbrechen" : "Profilbild auswählen"}
</button>
</div>
{#if showAvatars}
<div class="avatar-selection">
{#each avatars as avatar}
<button
class="avatar-option"
on:click={() => {
user.thumbnail = avatar.src;
showAvatars = false;
}}
>
<img src={avatar.src} alt={avatar.name} width="80" />
</button>
{/each}
</div>
{/if}
</div>
<form
class="content"
action="?/updateUser"
method="POST"
use:enhance={handleUpdate}
>
<h1 class="step-title" style="text-align: center;">{$t("user_edit")}</h1>
{#if form?.success}
<h4
class="step-subtitle warning"
in:receive={{ key: Math.floor(Math.random() * 100) }}
out:send={{ key: Math.floor(Math.random() * 100) }}
>
Um einen fehlerhaften upload Ihres Bildes zu vermeiden, clicke bitte
auf den "Update" Button unten.
</h4>
{/if}
{#if form?.errors}
{#each form?.errors as error (error.id)}
<h4
class="step-subtitle warning"
in:receive={{ key: error.id }}
out:send={{ key: error.id }}
>
{error.error}
</h4>
{/each}
{/if}
<input type="hidden" hidden name="thumbnail" value={user.thumbnail} />
<div class="button-container">
{#each TABS as tab}
<button
type="button"
class="button-dark"
class:active={activeTab === tab}
on:click={() => setActiveTab(tab)}
>
{$t(tab)}
</button>
{/each}
</div>
{#if activeTab == "profile"}
<div class="tab-content">
<InputField
name="password"
type="password"
label={$t("password")}
placeholder={$t("placeholder_password")}
/>
<InputField
name="password2"
type="password"
label={$t("password_repeat")}
placeholder={$t("placeholder_password")}
/>
<InputField
name="first_name"
label={$t("first_name")}
value={user.first_name}
placeholder={$t("placeholder_first_name")}
/>
<InputField
name="last_name"
label={$t("last_name")}
value={user.last_name}
placeholder={$t("placeholder_last_name")}
/>
<InputField
name="email"
type="email"
label={$t("email")}
value={user.email}
placeholder={$t("placeholder_email")}
/>
<InputField
name="phone"
type="tel"
label={$t("phone")}
value={user.phone || ""}
placeholder={$t("placeholder_phone")}
/>
<InputField
name="birth_date"
type="date"
label={$t("birth_date")}
value={user.birth_date || ""}
placeholder={$t("placeholder_birth_date")}
/>
<InputField
name="address"
label={$t("address")}
value={user.address || ""}
placeholder={$t("placeholder_address")}
/>
<InputField
name="zip_code"
label={$t("zip_code")}
value={user.zip_code || ""}
placeholder={$t("placeholder_zip_code")}
/>
<InputField
name="city"
label={$t("city")}
value={user.city || ""}
placeholder={$t("placeholder_city")}
/>
</div>
{:else if activeTab == "membership"}
<div class="tab-content">
<InputField
name="membership_status"
label={$t("status")}
value={user.membership?.status || ""}
/>
<InputField
name="membership_start_date"
type="date"
label={$t("start")}
value={user.membership?.start_date || ""}
placeholder={$t("placeholder_start_date")}
/>
<InputField
name="membership_end_date"
type="date"
label={$t("end")}
value={user.membership?.end_date || ""}
placeholder={$t("placeholder_end_date")}
/>
<InputField
name="parent_member_id"
type="number"
label={$t("parent_member_id")}
value={user.membership?.parent_member_id || ""}
placeholder={$t("placeholder_parent_member_id")}
/>
</div>
{:else if activeTab == "bankaccount"}
<div class="tab-content">
<InputField
name="account_holder_name"
label={$t("bank_account_holder")}
value={user.bank_account?.account_holder_name || ""}
placeholder={$t("placeholder_bank_account_holder")}
/>
<InputField
name="bank"
label={$t("bank_name")}
value={user.bank_account?.bank || ""}
placeholder={$t("placeholder_bank_name")}
/>
<InputField
name="iban"
label={$t("iban")}
value={user.bank_account?.iban || ""}
placeholder={$t("placeholder_iban")}
/>
<InputField
name="bic"
label={$t("bic")}
value={user.bank_account?.bic || ""}
placeholder={$t("placeholder_bic")}
/>
<InputField
name="mandate_reference"
label={$t("mandate_reference")}
value={user.bank_account?.mandate_reference || ""}
placeholder={$t("placeholder_mandate_reference")}
/>
</div>
{/if}
<div class="button-container">
{#if isUpdating}
<SmallLoader width={30} message={"Aktualisiere..."} />
{:else}
<button type="button" class="button-dark" on:click={close}
>Abbrechen</button
>
<button type="submit" class="button-dark">Bestätigen</button>
{/if}
</div>
</form>
</Modal>
{/if}
<style>
.tab-content {
padding: 1rem;
border-radius: 0 0 3px 3px;
}
.hero-container .hero-subtitle:not(:last-of-type) {
margin: 0 0 0 0;
}
.hero-container {
display: flex;
flex-direction: column;
align-items: center;
}
.user-info {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
align-items: start;
text-align: left;
margin-top: 1rem;
}
.info-row {
display: contents;
}
.label {
font-size: 1.3rem;
font-weight: bold;
text-align: left;
padding-right: 1rem;
}
.value {
margin: 0;
font-size: 1.2rem;
text-align: left;
}
.block-value {
display: flex;
align-items: flex-start;
flex-direction: column;
}
.hero-buttons-container {
margin-top: 1rem;
}
.user-info {
font-size: 1rem;
}
.label,
.value {
font-size: 1.1rem;
}
.avatar-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.avatar-form {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.current-avatar {
margin-bottom: 1rem;
}
.avatar-buttons {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
max-width: 200px;
}
.avatar-buttons button {
margin-top: 1.5rem;
width: 100%;
}
.avatar-selection {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-top: 1rem;
}
.avatar-option {
width: 80px;
height: 80px;
padding: 0;
border: none;
background: none;
cursor: pointer;
transition: transform 0.2s;
}
.avatar-option:hover,
.avatar-option:focus {
transform: scale(1.8);
}
.avatar-option img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.button-container {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
margin-top: 1rem;
width: 100%;
}
.button-container button {
flex: 1 1 0;
min-width: 120px;
max-width: calc(50%-5px);
}
@media (max-width: 480px) {
.button-container button {
flex-basis: 100%;
max-width: none;
}
}
</style>

View File

@@ -2,6 +2,7 @@
import { applyAction, enhance } from "$app/forms"; import { applyAction, enhance } from "$app/forms";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { receive, send } from "$lib/utils/helpers"; import { receive, send } from "$lib/utils/helpers";
import { t } from "svelte-i18n";
/** @type {import('./$types').ActionData} */ /** @type {import('./$types').ActionData} */
export let form; export let form;
@@ -26,7 +27,7 @@
action="?/login" action="?/login"
use:enhance={handleLogin} use:enhance={handleLogin}
> >
<h1 class="step-title">Login User</h1> <h1 class="step-title">{$t("user_login")}</h1>
{#if form?.errors} {#if form?.errors}
{#each form?.errors as error (error.id)} {#each form?.errors as error (error.id)}
<h4 <h4
@@ -49,28 +50,45 @@
value={$page.url.searchParams.get("next")} value={$page.url.searchParams.get("next")}
/> />
<div class="input-box"> <div class="input-box">
<span class="label">Email:</span> <span class="label">{$t("email")}:</span>
<input <input
class="input" class="input"
type="email" type="email"
name="email" name="email"
placeholder="Email address" placeholder="{$t('placeholder_email')} "
/> />
</div> </div>
<div class="input-box"> <div class="input-box">
<span class="label">Password:</span> <span class="label">{$t("password")}:</span>
<div class="input-wrapper">
<input <input
class="input" class="input"
type="password" type="password"
name="password" name="password"
placeholder="Password" placeholder={$t("placeholder_password")}
/> />
<a href="/auth/password/request-change" style="margin-left: 1rem;" <a href="/auth/password/request-change" class="forgot-password"
>Forgot password?</a >{$t("forgot_password")}?</a
> >
</div> </div>
</div>
<div class="btn-container"> <div class="btn-container">
<button class="button-dark">Login</button> <button class="button-dark">{$t("login")} </button>
</div> </div>
</form> </form>
</div> </div>
<style>
.input-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
width: 100%;
max-width: 444px;
}
.forgot-password {
margin-top: 0.5rem;
font-size: 0.9em;
}
</style>