feat(frontend): start settings

This commit is contained in:
sam 2024-11-24 17:36:02 +01:00
parent 0c78cd25b0
commit c179669799
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
13 changed files with 301 additions and 17 deletions

View file

@ -17,6 +17,7 @@
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@sveltestrap/sveltestrap": "^6.2.7", "@sveltestrap/sveltestrap": "^6.2.7",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
"@types/luxon": "^3.4.2",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.13.0", "@types/sanitize-html": "^2.13.0",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
@ -38,6 +39,7 @@
"dependencies": { "dependencies": {
"@fontsource/firago": "^5.1.0", "@fontsource/firago": "^5.1.0",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"luxon": "^3.5.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"sanitize-html": "^2.13.1", "sanitize-html": "^2.13.1",
"tslog": "^4.9.3" "tslog": "^4.9.3"

View file

@ -14,6 +14,9 @@ importers:
bootstrap-icons: bootstrap-icons:
specifier: ^1.11.3 specifier: ^1.11.3
version: 1.11.3 version: 1.11.3
luxon:
specifier: ^3.5.0
version: 3.5.0
markdown-it: markdown-it:
specifier: ^14.1.0 specifier: ^14.1.0
version: 14.1.0 version: 14.1.0
@ -39,6 +42,9 @@ importers:
'@types/eslint': '@types/eslint':
specifier: ^9.6.0 specifier: ^9.6.0
version: 9.6.1 version: 9.6.1
'@types/luxon':
specifier: ^3.4.2
version: 3.4.2
'@types/markdown-it': '@types/markdown-it':
specifier: ^14.1.2 specifier: ^14.1.2
version: 14.1.2 version: 14.1.2
@ -590,6 +596,9 @@ packages:
'@types/linkify-it@5.0.0': '@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/luxon@3.4.2':
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
'@types/markdown-it@14.1.2': '@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
@ -1063,6 +1072,10 @@ packages:
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
magic-string@0.30.12: magic-string@0.30.12:
resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==}
@ -1800,6 +1813,8 @@ snapshots:
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
'@types/luxon@3.4.2': {}
'@types/markdown-it@14.1.2': '@types/markdown-it@14.1.2':
dependencies: dependencies:
'@types/linkify-it': 5.0.0 '@types/linkify-it': 5.0.0
@ -2302,6 +2317,8 @@ snapshots:
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
luxon@3.5.0: {}
magic-string@0.30.12: magic-string@0.30.12:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0

View file

@ -23,3 +23,20 @@
@import "@fontsource/firago/400-italic.css"; @import "@fontsource/firago/400-italic.css";
@import "@fontsource/firago/700.css"; @import "@fontsource/firago/700.css";
@import "@fontsource/firago/700-italic.css"; @import "@fontsource/firago/700-italic.css";
// This is necessary for line breaks in translation strings to show up. Don't ask me why
.text-has-newline {
white-space: pre-line;
}
// Add breakpoint-dependent w-{size} utilities
// Source: https://stackoverflow.com/questions/47760132/any-way-to-get-breakpoint-specific-width-classes
@each $breakpoint in map-keys(bootstrap.$grid-breakpoints) {
@each $size, $length in (25: 25%, 50: 50%, 75: 75%, 100: 100%) {
@include bootstrap.media-breakpoint-up($breakpoint) {
.w-#{$breakpoint}-#{$size} {
width: $length !important;
}
}
}
}

View file

@ -52,3 +52,15 @@ export type ValidationError = {
allowed_values?: any[]; allowed_values?: any[];
actual_value?: any; actual_value?: any;
}; };
/**
* Returns the first error for the value `key` in `error`.
* @param error The error object to traverse.
* @param key The JSON key to find.
*/
export const firstErrorFor = (error: RawApiError, key: string): ValidationError | undefined => {
if (!error.errors) return undefined;
const field = error.errors.find((e) => e.key == key);
if (!field?.errors) return undefined;
return field.errors.length != 0 ? field.errors[0] : undefined;
};

View file

@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { DEFAULT_AVATAR } from "$lib"; import { DEFAULT_AVATAR } from "$lib";
type Props = { url: string | null; alt: string; lazyLoad?: boolean }; type Props = { url: string | null; alt: string; lazyLoad?: boolean; width?: number };
let { url, alt, lazyLoad }: Props = $props(); let { url, alt, lazyLoad, width }: Props = $props();
</script> </script>
<img <img
class="rounded-circle img-fluid" class="rounded-circle img-fluid"
src={url || DEFAULT_AVATAR} src={url || DEFAULT_AVATAR}
{alt} {alt}
width={200} width={width || 200}
loading={lazyLoad ? "lazy" : "eager"} loading={lazyLoad ? "lazy" : "eager"}
/> />

View file

@ -4,17 +4,19 @@
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import KeyedValidationErrors from "./errors/KeyedValidationErrors.svelte"; import KeyedValidationErrors from "./errors/KeyedValidationErrors.svelte";
type Props = { headerElem?: string; error: RawApiError }; type Props = { showHeader?: boolean; headerElem?: string; error: RawApiError };
let { headerElem, error }: Props = $props(); let { showHeader, headerElem, error }: Props = $props();
</script> </script>
<svelte:element this={headerElem ?? "h4"}> {#if showHeader !== false}
{#if error.code === ErrorCode.BadRequest} <svelte:element this={headerElem ?? "h4"}>
{$t("error.bad-request-header")} {#if error.code === ErrorCode.BadRequest}
{:else} {$t("error.bad-request-header")}
{$t("error.generic-header")} {:else}
{/if} {$t("error.generic-header")}
</svelte:element> {/if}
</svelte:element>
{/if}
<p>{errorDescription($t, error.code)}</p> <p>{errorDescription($t, error.code)}</p>
{#if error.errors} {#if error.errors}
<details> <details>

View file

@ -16,7 +16,8 @@
}, },
"title": { "title": {
"log-in": "Log in", "log-in": "Log in",
"welcome": "Welcome" "welcome": "Welcome",
"settings": "Settings"
}, },
"auth": { "auth": {
"log-in-form-title": "Log in with email", "log-in-form-title": "Log in with email",
@ -59,5 +60,32 @@
"validation-reason": "Reason", "validation-reason": "Reason",
"validation-generic": "The value you entered is not allowed here. Reason", "validation-generic": "The value you entered is not allowed here. Reason",
"extra-info-header": "Extra error information" "extra-info-header": "Extra error information"
} },
"settings": {
"general-information-tab": "General information",
"your-profile-tab": "Your profile",
"members-tab": "Members",
"authentication-tab": "Authentication",
"export-tab": "Export your data",
"change-username-button": "Change username",
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
"change-avatar-link": "Change your avatar here",
"new-username": "New username",
"table-role": "Role",
"table-custom-preferences": "Custom preferences",
"table-member-list-hidden": "Member list hidden?",
"table-member-count": "Member count",
"table-created-at": "Account created at",
"table-id": "Your ID",
"table-title": "Account information",
"force-log-out-title": "Log out everywhere",
"force-log-out-button": "Force log out",
"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
"log-out-title": "Log out",
"log-out-hint": "Use this button to log out on this device only.",
"log-out-button": "Log out"
},
"yes": "Yes",
"no": "No"
} }

View file

@ -1,6 +1,7 @@
// place files you want to import through the `$lib` alias in this folder. // place files you want to import through the `$lib` alias in this folder.
import type { Cookies } from "@sveltejs/kit"; import type { Cookies } from "@sveltejs/kit";
import { DateTime } from "luxon";
export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token"; export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token";
@ -10,3 +11,6 @@ export const clearToken = (cookies: Cookies) => cookies.delete(TOKEN_COOKIE_NAME
// TODO: change this to something we actually clearly have the rights to use // TODO: change this to something we actually clearly have the rights to use
export const DEFAULT_AVATAR = "https://pronouns.cc/default/512.webp"; export const DEFAULT_AVATAR = "https://pronouns.cc/default/512.webp";
export const idTimestamp = (id: string) =>
DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000);

View file

@ -1,15 +1,15 @@
import { clearToken, TOKEN_COOKIE_NAME } from "$lib"; import { clearToken, TOKEN_COOKIE_NAME } from "$lib";
import { apiRequest } from "$api"; import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error"; import ApiError, { ErrorCode } from "$api/error";
import type { Meta, User } from "$api/models"; import type { Meta, MeUser } from "$api/models";
import log from "$lib/log"; import log from "$lib/log";
import type { LayoutServerLoad } from "./$types"; import type { LayoutServerLoad } from "./$types";
export const load = (async ({ fetch, cookies }) => { export const load = (async ({ fetch, cookies }) => {
let meUser: User | null = null; let meUser: MeUser | null = null;
if (cookies.get(TOKEN_COOKIE_NAME)) { if (cookies.get(TOKEN_COOKIE_NAME)) {
try { try {
meUser = await apiRequest<User>("GET", "/users/@me", { fetch, cookies }); meUser = await apiRequest<MeUser>("GET", "/users/@me", { fetch, cookies });
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies); if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies);
else log.error("Could not fetch /users/@me and token has not expired:", e); else log.error("Could not fetch /users/@me and token has not expired:", e);

View file

@ -0,0 +1,8 @@
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => {
const data = await parent();
if (!data.meUser) redirect(303, "/auth/log-in");
return { user: data.meUser! };
};

View file

@ -0,0 +1,44 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { page } from "$app/stores";
import { t } from "$lib/i18n";
import { Nav, NavLink } from "@sveltestrap/sveltestrap";
type Props = { children: Snippet };
let { children }: Props = $props();
const isActive = (path: string | string[], prefix: boolean = false) =>
typeof path === "string"
? prefix
? $page.url.pathname.startsWith(path)
: $page.url.pathname === path
: prefix
? path.some((p) => $page.url.pathname.startsWith(p))
: path.some((p) => $page.url.pathname === p);
</script>
<svelte:head>
<title>{$t("title.settings")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<Nav pills justified fill class="flex-column flex-md-row mb-2">
<NavLink active={isActive(["/settings", "/settings/force-log-out"])} href="/settings">
{$t("settings.general-information-tab")}
</NavLink>
<NavLink active={isActive("/settings/profile", true)} href="/settings/profile">
{$t("settings.your-profile-tab")}
</NavLink>
<NavLink active={isActive("/settings/members", true)} href="/settings/members">
{$t("settings.members-tab")}
</NavLink>
<NavLink active={isActive("/settings/auth", true)} href="/settings/auth">
{$t("settings.authentication-tab")}
</NavLink>
<NavLink active={isActive("/settings/export")} href="/settings/export">
{$t("settings.export-tab")}
</NavLink>
</Nav>
{@render children?.()}
</div>

View file

@ -0,0 +1,37 @@
import { fastRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import { clearToken } from "$lib";
import { redirect } from "@sveltejs/kit";
export const actions = {
logout: async ({ cookies }) => {
clearToken(cookies);
redirect(303, "/");
},
changeUsername: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const username = body.get("username") as string | null;
if (username == null)
return {
error: {
status: 403,
code: ErrorCode.BadRequest,
message: "Invalid username",
} as RawApiError,
ok: false,
};
try {
await fastRequest("PATCH", "/users/@me", {
fetch,
cookies,
body: { username },
});
return { error: null, ok: true };
} catch (e) {
if (e instanceof ApiError) return { error: e.obj, ok: false };
throw e;
}
},
};

View file

@ -0,0 +1,113 @@
<script lang="ts">
import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n";
import { Button, FormGroup, Icon, Input, InputGroup, Label } from "@sveltestrap/sveltestrap";
import Avatar from "$components/Avatar.svelte";
import { firstErrorFor } from "$api/error";
import Error from "$components/Error.svelte";
import { idTimestamp } from "$lib";
import { DateTime } from "luxon";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
let usernameError = $derived(form?.error ? firstErrorFor(form.error, "username") : undefined);
let createdAt = $derived(idTimestamp(data.user.id));
</script>
<h3>{$t("settings.general-information-tab")}</h3>
<div class="row mb-3">
<div class="col-md-9">
<h5>Change your username</h5>
<form method="POST" action="?/changeUsername">
<FormGroup class="mb-3">
<InputGroup class="m-1 mt-3 w-md-75">
<Input type="text" value={data.user.username} name="username" required />
<Button type="submit" color="secondary">{$t("settings.change-username-button")}</Button>
</InputGroup>
</FormGroup>
{#if form?.ok}
<p class="text-success-emphasis">
<Icon name="check-circle-fill" /> Successfully changed your username!
</p>
{:else if usernameError}
<p class="text-danger-emphasis text-has-newline">
<Icon name="exclamation-triangle-fill" />
{$t("settings.username-update-error", { message: usernameError.message })}
</p>
{:else if form?.error}
<Error showHeader={false} error={form?.error} />
{/if}
</form>
<p class="text-muted text-has-newline">
<Icon name="info-circle-fill" aria-hidden />
{$t("settings.username-change-hint")}
</p>
</div>
<div class="col-md-3 text-center">
<h5>Avatar</h5>
<Avatar
url={data.user.avatar_url}
alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
/>
<p class="mt-2">
<a href="/settings/profile">{$t("settings.change-avatar-link")}</a>
</p>
</div>
</div>
<div class="mb-3">
<h4>{$t("settings.log-out-title")}</h4>
<p>{$t("settings.log-out-hint")}</p>
<form method="POST" action="?/logout">
<Button color="secondary" type="submit">{$t("settings.log-out-button")}</Button>
</form>
</div>
<div class="mb-3">
<h4>{$t("settings.force-log-out-title")}</h4>
<p>{$t("settings.force-log-out-hint")}</p>
<a class="btn btn-danger" href="/settings/force-log-out">{$t("settings.force-log-out-button")}</a>
</div>
<div>
<h4>{$t("settings.table-title")}</h4>
<table class="table table-striped table-hover table-bordered">
<tbody>
<tr>
<th scope="row">{$t("settings.table-id")}</th>
<td>
<code>{data.user.id}</code>
</td>
</tr>
<tr>
<th scope="row">{$t("settings.table-created-at")}</th>
<td>{createdAt.toLocaleString(DateTime.DATETIME_MED)}</td>
</tr>
<tr>
<th scope="row">{$t("settings.table-member-count")}</th>
<td>
{data.user.members.length}/{data.meta.limits.member_count}
</td>
</tr>
<tr>
<th scope="row">{$t("settings.table-member-list-hidden")}</th>
<td>{data.user.member_list_hidden ? $t("yes") : $t("no")}</td>
</tr>
<tr>
<th scope="row">{$t("settings.table-custom-preferences")}</th>
<td>
{Object.keys(data.user.custom_preferences).length}/{data.meta.limits.custom_preferences}
</td>
</tr>
<tr>
<th scope="row">{$t("settings.table-role")}</th>
<td>
<code>{data.user.role}</code>
</td>
</tr>
</tbody>
</table>
</div>