feat(frontend): tweak user/member layout, add start of edit profile page
This commit is contained in:
parent
d4b8a20b4d
commit
e4f3b26107
7 changed files with 285 additions and 61 deletions
|
@ -38,6 +38,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@popperjs/core": "^2.11.6",
|
"@popperjs/core": "^2.11.6",
|
||||||
|
"base64-arraybuffer": "^1.0.2",
|
||||||
"bootstrap": "5.3.0-alpha1",
|
"bootstrap": "5.3.0-alpha1",
|
||||||
"bootstrap-icons": "^1.10.3",
|
"bootstrap-icons": "^1.10.3",
|
||||||
"marked": "^4.2.12",
|
"marked": "^4.2.12",
|
||||||
|
|
|
@ -12,6 +12,7 @@ specifiers:
|
||||||
'@typescript-eslint/eslint-plugin': ^5.45.0
|
'@typescript-eslint/eslint-plugin': ^5.45.0
|
||||||
'@typescript-eslint/parser': ^5.45.0
|
'@typescript-eslint/parser': ^5.45.0
|
||||||
autoprefixer: ^10.4.13
|
autoprefixer: ^10.4.13
|
||||||
|
base64-arraybuffer: ^1.0.2
|
||||||
bootstrap: 5.3.0-alpha1
|
bootstrap: 5.3.0-alpha1
|
||||||
bootstrap-icons: ^1.10.3
|
bootstrap-icons: ^1.10.3
|
||||||
eslint: ^8.28.0
|
eslint: ^8.28.0
|
||||||
|
@ -32,6 +33,7 @@ specifiers:
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@popperjs/core': 2.11.6
|
'@popperjs/core': 2.11.6
|
||||||
|
base64-arraybuffer: 1.0.2
|
||||||
bootstrap: 5.3.0-alpha1_@popperjs+core@2.11.6
|
bootstrap: 5.3.0-alpha1_@popperjs+core@2.11.6
|
||||||
bootstrap-icons: 1.10.3
|
bootstrap-icons: 1.10.3
|
||||||
marked: 4.2.12
|
marked: 4.2.12
|
||||||
|
@ -768,6 +770,11 @@ packages:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/base64-arraybuffer/1.0.2:
|
||||||
|
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/binary-extensions/2.2.0:
|
/binary-extensions/2.2.0:
|
||||||
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
|
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
|
@ -4,12 +4,13 @@
|
||||||
|
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
import { Icon } from "sveltestrap";
|
import { Alert, Icon } from "sveltestrap";
|
||||||
import FieldCard from "$lib/components/FieldCard.svelte";
|
import FieldCard from "$lib/components/FieldCard.svelte";
|
||||||
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
||||||
import PronounLink from "$lib/components/PronounLink.svelte";
|
import PronounLink from "$lib/components/PronounLink.svelte";
|
||||||
import PartialMemberCard from "$lib/components/PartialMemberCard.svelte";
|
import PartialMemberCard from "$lib/components/PartialMemberCard.svelte";
|
||||||
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||||
|
import { userStore } from "$lib/store";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
|
@ -22,33 +23,41 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="grid">
|
{#if $userStore && $userStore.id === data.id}
|
||||||
|
<Alert color="secondary" fade={false}>
|
||||||
|
You are currently viewing your <strong>public</strong> profile.
|
||||||
|
<br /><a href="/edit/profile">Edit your profile</a>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
<div class="grid row-gap-3">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md text-center">
|
<div class="col-md text-center">
|
||||||
<FallbackImage urls={data.avatar_urls} alt="Avatar for @{data.name}" />
|
<FallbackImage width={200} urls={data.avatar_urls} alt="Avatar for @{data.name}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md">
|
||||||
{#if data.display_name}
|
{#if data.display_name}
|
||||||
|
<div>
|
||||||
<h2>{data.display_name}</h2>
|
<h2>{data.display_name}</h2>
|
||||||
<h5 class="text-body-secondary">@{data.name}</h5>
|
<h5 class="text-body-secondary">@{data.name}</h5>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<h2>@{data.name}</h2>
|
<h2>@{data.name}</h2>
|
||||||
{/if}
|
{/if}
|
||||||
<hr />
|
|
||||||
{#if bio}
|
{#if bio}
|
||||||
|
<hr />
|
||||||
<p>{@html bio}</p>
|
<p>{@html bio}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{#if data.links.length > 0}
|
{#if data.links.length > 0}
|
||||||
<hr />
|
<div class="col-md d-flex align-items-center">
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
{#each data.links as link}
|
{#each data.links as link}
|
||||||
<li><Icon name="globe" /> <a href={link}>{link}</a></li>
|
<li><Icon name="globe" /> <a href={link}>{link}</a></li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
||||||
{#if data.names.length > 0}
|
{#if data.names.length > 0}
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
|
@ -93,3 +102,4 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
@ -29,24 +29,25 @@
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md text-center">
|
<div class="col-md text-center">
|
||||||
<FallbackImage urls={data.avatar_urls} alt="Avatar for @{data.name}" />
|
<FallbackImage width={200} urls={data.avatar_urls} alt="Avatar for @{data.name}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md">
|
||||||
<h2>{data.display_name ?? data.name}</h2>
|
<h2>{data.display_name ?? data.name}</h2>
|
||||||
<h5 class="text-body-secondary">{data.name} (@{data.user.name})</h5>
|
<h5 class="text-body-secondary">{data.name} (@{data.user.name})</h5>
|
||||||
<hr />
|
|
||||||
{#if bio}
|
{#if bio}
|
||||||
|
<hr />
|
||||||
<p>{@html bio}</p>
|
<p>{@html bio}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{#if data.links.length > 0}
|
{#if data.links.length > 0}
|
||||||
<hr />
|
<div class="col-md d-flex align-items-center">
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
{#each data.links as link}
|
{#each data.links as link}
|
||||||
<li><Icon name="globe" /> <a href={link}>{link}</a></li>
|
<li><Icon name="globe" /> <a href={link}>{link}</a></li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
||||||
{#if data.names.length > 0}
|
{#if data.names.length > 0}
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (data.token && data.user) {
|
if (data.token && data.user) {
|
||||||
localStorage.setItem("pronouns-token", data.token);
|
localStorage.setItem("pronouns-token", data.token);
|
||||||
|
localStorage.setItem("pronouns-user", JSON.stringify(data.user));
|
||||||
userStore.set(data.user);
|
userStore.set(data.user);
|
||||||
goto("/");
|
goto("/");
|
||||||
}
|
}
|
||||||
|
@ -38,6 +39,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
localStorage.setItem("pronouns-token", resp.token);
|
localStorage.setItem("pronouns-token", resp.token);
|
||||||
|
localStorage.setItem("pronouns-user", JSON.stringify(resp.user));
|
||||||
userStore.set(resp.user);
|
userStore.set(resp.user);
|
||||||
goto("/");
|
goto("/");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -53,7 +55,7 @@
|
||||||
<h1>Log in with Discord</h1>
|
<h1>Log in with Discord</h1>
|
||||||
|
|
||||||
{#if data.error}
|
{#if data.error}
|
||||||
<Alert color="danger">
|
<Alert color="danger" fade={false}>
|
||||||
<h4 class="alert-heading">An error occurred</h4>
|
<h4 class="alert-heading">An error occurred</h4>
|
||||||
<b>{data.error.code}:</b>
|
<b>{data.error.code}:</b>
|
||||||
{data.error.message}
|
{data.error.message}
|
||||||
|
|
202
frontend/src/routes/edit/profile/+page.svelte
Normal file
202
frontend/src/routes/edit/profile/+page.svelte
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import type { APIError, Field, FieldEntry, MeUser, Pronoun } from "$lib/api/entities";
|
||||||
|
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||||
|
import { userStore } from "$lib/store";
|
||||||
|
import { Alert, Button, FormGroup, Input } from "sveltestrap";
|
||||||
|
import { encode } from "base64-arraybuffer";
|
||||||
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
|
|
||||||
|
const MAX_AVATAR_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
if (!$userStore) {
|
||||||
|
goto("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
let error: APIError | null = null;
|
||||||
|
|
||||||
|
let bio: string = $userStore?.bio || "";
|
||||||
|
let display_name: string = $userStore?.display_name || "";
|
||||||
|
let names: FieldEntry[] = $userStore ? window.structuredClone($userStore.names) : [];
|
||||||
|
let pronouns: Pronoun[] = $userStore ? window.structuredClone($userStore.pronouns) : [];
|
||||||
|
let fields: Field[] = $userStore ? window.structuredClone($userStore.fields) : [];
|
||||||
|
|
||||||
|
let avatar: string | null;
|
||||||
|
let avatar_files: FileList | null;
|
||||||
|
|
||||||
|
let modified = false;
|
||||||
|
|
||||||
|
$: redirectIfNoAuth($userStore);
|
||||||
|
$: modified = isModified(bio, display_name, names, pronouns, fields);
|
||||||
|
$: getAvatar(avatar_files).then((b64) => (avatar = b64));
|
||||||
|
|
||||||
|
const redirectIfNoAuth = (user: MeUser | null) => {
|
||||||
|
if (!user) {
|
||||||
|
goto("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isModified = (
|
||||||
|
bio: string,
|
||||||
|
display_name: string,
|
||||||
|
names: FieldEntry[],
|
||||||
|
pronouns: Pronoun[],
|
||||||
|
fields: Field[],
|
||||||
|
) => {
|
||||||
|
if (!$userStore) return false;
|
||||||
|
|
||||||
|
if (bio !== $userStore.bio) return true;
|
||||||
|
if (display_name !== $userStore.display_name) return true;
|
||||||
|
if (!fieldsEqual(fields, $userStore.fields)) return true;
|
||||||
|
if (!namesEqual(names, $userStore.names)) return true;
|
||||||
|
if (!pronounsEqual(pronouns, $userStore.pronouns)) return true;
|
||||||
|
if (avatar !== null) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldsEqual = (arr1: Field[], arr2: Field[]) => {
|
||||||
|
if (arr1?.length !== arr2?.length) return false;
|
||||||
|
if (!arr1.every((_, i) => arr1[i].name === arr2[i].name)) return false;
|
||||||
|
|
||||||
|
return arr1.every((_, i) =>
|
||||||
|
arr1[i].entries.every(
|
||||||
|
(entry, j) =>
|
||||||
|
entry.value === arr2[i].entries[j].value && entry.status === arr2[i].entries[j].status,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const namesEqual = (arr1: FieldEntry[], arr2: FieldEntry[]) => {
|
||||||
|
if (arr1?.length !== arr2?.length) return false;
|
||||||
|
if (!arr1.every((_, i) => arr1[i].value === arr2[i].value)) return false;
|
||||||
|
if (!arr1.every((_, i) => arr1[i].status === arr2[i].status)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pronounsEqual = (arr1: Pronoun[], arr2: Pronoun[]) => {
|
||||||
|
if (arr1?.length !== arr2?.length) return false;
|
||||||
|
if (!arr1.every((_, i) => arr1[i].pronouns === arr2[i].pronouns)) return false;
|
||||||
|
if (!arr1.every((_, i) => arr1[i].display_text === arr2[i].display_text)) return false;
|
||||||
|
if (!arr1.every((_, i) => arr1[i].status === arr2[i].status)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvatar = async (list: FileList | null) => {
|
||||||
|
if (!list || list.length === 0) return null;
|
||||||
|
if (list[0].size > MAX_AVATAR_BYTES) return null;
|
||||||
|
|
||||||
|
console.log(list[0].type);
|
||||||
|
|
||||||
|
const buffer = await list[0].arrayBuffer();
|
||||||
|
const base64 = encode(buffer);
|
||||||
|
|
||||||
|
const uri = `data:${dataTypeFromFilename(list[0].name)};base64,${base64}`;
|
||||||
|
console.log(uri.slice(0, 256));
|
||||||
|
|
||||||
|
return uri;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataTypeFromFilename = (filename: string) => {
|
||||||
|
if (filename.endsWith(".webp")) {
|
||||||
|
return "image/webp";
|
||||||
|
} else if (filename.endsWith(".jpg") || filename.endsWith(".jpeg")) {
|
||||||
|
return "image/jpeg";
|
||||||
|
} else if (filename.endsWith(".png")) {
|
||||||
|
return "image/png";
|
||||||
|
} else if (filename.endsWith(".gif")) {
|
||||||
|
return "image/gif";
|
||||||
|
} else {
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUser = async () => {
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
display_name,
|
||||||
|
avatar,
|
||||||
|
bio,
|
||||||
|
names,
|
||||||
|
pronouns,
|
||||||
|
fields,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resp = await apiFetchClient<MeUser>("/users/@me", "PATCH", {
|
||||||
|
display_name,
|
||||||
|
avatar,
|
||||||
|
bio,
|
||||||
|
names,
|
||||||
|
pronouns,
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
userStore.set(resp);
|
||||||
|
localStorage.setItem("pronouns-user", JSON.stringify(resp));
|
||||||
|
|
||||||
|
error = null;
|
||||||
|
modified = false;
|
||||||
|
} catch (e) {
|
||||||
|
error = e as APIError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
Edit profile
|
||||||
|
{#if modified}
|
||||||
|
<Button color="success" on:click={() => updateUser()}>Save changes</Button>
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<Alert color="danger" fade={false}>
|
||||||
|
<h4 class="alert-header">An error occurred</h4>
|
||||||
|
<p>
|
||||||
|
<b>{error.code}</b>: {error.message}
|
||||||
|
{#if error.details}{error.details}{/if}
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !$userStore}
|
||||||
|
<Alert color="danger" fade={false}>Error: No user object</Alert>
|
||||||
|
{:else}
|
||||||
|
<div class="grid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<FormGroup floating label="Username">
|
||||||
|
<Input value={$userStore.name} disabled />
|
||||||
|
</FormGroup>
|
||||||
|
<span>To change your username, go to <a href="/settings">settings</a>.</span>
|
||||||
|
<h3>Avatar</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
{#if avatar}
|
||||||
|
<img width={200} src={avatar} alt="New avatar" class="rounded-circle img-fluid" />
|
||||||
|
{:else}
|
||||||
|
<FallbackImage alt="Current avatar" urls={$userStore.avatar_urls} width={200} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<input class="form-control" id="avatar" type="file" bind:files={avatar_files} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<FormGroup floating label="Display name">
|
||||||
|
<Input bind:value={display_name} />
|
||||||
|
</FormGroup>
|
||||||
|
<div>
|
||||||
|
<FormGroup floating label="Bio ({bio.length}/1000)">
|
||||||
|
<textarea style="min-height: 100px;" class="form-control" bind:value={bio} />
|
||||||
|
</FormGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
1
frontend/src/routes/edit/profile/+page.ts
Normal file
1
frontend/src/routes/edit/profile/+page.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const ssr = false;
|
Loading…
Reference in a new issue