feat(frontend): start settings
This commit is contained in:
parent
0c78cd25b0
commit
c179669799
13 changed files with 301 additions and 17 deletions
|
@ -17,6 +17,7 @@
|
|||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@sveltestrap/sveltestrap": "^6.2.7",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/sanitize-html": "^2.13.0",
|
||||
"bootstrap": "^5.3.3",
|
||||
|
@ -38,6 +39,7 @@
|
|||
"dependencies": {
|
||||
"@fontsource/firago": "^5.1.0",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"luxon": "^3.5.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"sanitize-html": "^2.13.1",
|
||||
"tslog": "^4.9.3"
|
||||
|
|
|
@ -14,6 +14,9 @@ importers:
|
|||
bootstrap-icons:
|
||||
specifier: ^1.11.3
|
||||
version: 1.11.3
|
||||
luxon:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
markdown-it:
|
||||
specifier: ^14.1.0
|
||||
version: 14.1.0
|
||||
|
@ -39,6 +42,9 @@ importers:
|
|||
'@types/eslint':
|
||||
specifier: ^9.6.0
|
||||
version: 9.6.1
|
||||
'@types/luxon':
|
||||
specifier: ^3.4.2
|
||||
version: 3.4.2
|
||||
'@types/markdown-it':
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.2
|
||||
|
@ -590,6 +596,9 @@ packages:
|
|||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
'@types/luxon@3.4.2':
|
||||
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||
|
||||
|
@ -1063,6 +1072,10 @@ packages:
|
|||
lodash.merge@4.6.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==}
|
||||
|
||||
|
@ -1800,6 +1813,8 @@ snapshots:
|
|||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/luxon@3.4.2': {}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
dependencies:
|
||||
'@types/linkify-it': 5.0.0
|
||||
|
@ -2302,6 +2317,8 @@ snapshots:
|
|||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
luxon@3.5.0: {}
|
||||
|
||||
magic-string@0.30.12:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
|
|
@ -23,3 +23,20 @@
|
|||
@import "@fontsource/firago/400-italic.css";
|
||||
@import "@fontsource/firago/700.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,3 +52,15 @@ export type ValidationError = {
|
|||
allowed_values?: 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;
|
||||
};
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { DEFAULT_AVATAR } from "$lib";
|
||||
|
||||
type Props = { url: string | null; alt: string; lazyLoad?: boolean };
|
||||
let { url, alt, lazyLoad }: Props = $props();
|
||||
type Props = { url: string | null; alt: string; lazyLoad?: boolean; width?: number };
|
||||
let { url, alt, lazyLoad, width }: Props = $props();
|
||||
</script>
|
||||
|
||||
<img
|
||||
class="rounded-circle img-fluid"
|
||||
src={url || DEFAULT_AVATAR}
|
||||
{alt}
|
||||
width={200}
|
||||
width={width || 200}
|
||||
loading={lazyLoad ? "lazy" : "eager"}
|
||||
/>
|
||||
|
|
|
@ -4,10 +4,11 @@
|
|||
import { t } from "$lib/i18n";
|
||||
import KeyedValidationErrors from "./errors/KeyedValidationErrors.svelte";
|
||||
|
||||
type Props = { headerElem?: string; error: RawApiError };
|
||||
let { headerElem, error }: Props = $props();
|
||||
type Props = { showHeader?: boolean; headerElem?: string; error: RawApiError };
|
||||
let { showHeader, headerElem, error }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if showHeader !== false}
|
||||
<svelte:element this={headerElem ?? "h4"}>
|
||||
{#if error.code === ErrorCode.BadRequest}
|
||||
{$t("error.bad-request-header")}
|
||||
|
@ -15,6 +16,7 @@
|
|||
{$t("error.generic-header")}
|
||||
{/if}
|
||||
</svelte:element>
|
||||
{/if}
|
||||
<p>{errorDescription($t, error.code)}</p>
|
||||
{#if error.errors}
|
||||
<details>
|
||||
|
|
|
@ -16,7 +16,8 @@
|
|||
},
|
||||
"title": {
|
||||
"log-in": "Log in",
|
||||
"welcome": "Welcome"
|
||||
"welcome": "Welcome",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"auth": {
|
||||
"log-in-form-title": "Log in with email",
|
||||
|
@ -59,5 +60,32 @@
|
|||
"validation-reason": "Reason",
|
||||
"validation-generic": "The value you entered is not allowed here. Reason",
|
||||
"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"
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
||||
|
||||
import type { Cookies } from "@sveltejs/kit";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
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
|
||||
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);
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import { clearToken, TOKEN_COOKIE_NAME } from "$lib";
|
||||
import { apiRequest } from "$api";
|
||||
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 type { LayoutServerLoad } from "./$types";
|
||||
|
||||
export const load = (async ({ fetch, cookies }) => {
|
||||
let meUser: User | null = null;
|
||||
let meUser: MeUser | null = null;
|
||||
if (cookies.get(TOKEN_COOKIE_NAME)) {
|
||||
try {
|
||||
meUser = await apiRequest<User>("GET", "/users/@me", { fetch, cookies });
|
||||
meUser = await apiRequest<MeUser>("GET", "/users/@me", { fetch, cookies });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies);
|
||||
else log.error("Could not fetch /users/@me and token has not expired:", e);
|
||||
|
|
8
Foxnouns.Frontend/src/routes/settings/+layout.server.ts
Normal file
8
Foxnouns.Frontend/src/routes/settings/+layout.server.ts
Normal 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! };
|
||||
};
|
44
Foxnouns.Frontend/src/routes/settings/+layout.svelte
Normal file
44
Foxnouns.Frontend/src/routes/settings/+layout.svelte
Normal 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>
|
37
Foxnouns.Frontend/src/routes/settings/+page.server.ts
Normal file
37
Foxnouns.Frontend/src/routes/settings/+page.server.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
113
Foxnouns.Frontend/src/routes/settings/+page.svelte
Normal file
113
Foxnouns.Frontend/src/routes/settings/+page.svelte
Normal 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>
|
Loading…
Reference in a new issue