diff --git a/Foxnouns.Frontend/package.json b/Foxnouns.Frontend/package.json index 1d5739a..69d4a1f 100644 --- a/Foxnouns.Frontend/package.json +++ b/Foxnouns.Frontend/package.json @@ -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" diff --git a/Foxnouns.Frontend/pnpm-lock.yaml b/Foxnouns.Frontend/pnpm-lock.yaml index ca6df0f..d9bd974 100644 --- a/Foxnouns.Frontend/pnpm-lock.yaml +++ b/Foxnouns.Frontend/pnpm-lock.yaml @@ -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 diff --git a/Foxnouns.Frontend/src/app.scss b/Foxnouns.Frontend/src/app.scss index 252667f..4a9d5dd 100644 --- a/Foxnouns.Frontend/src/app.scss +++ b/Foxnouns.Frontend/src/app.scss @@ -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; + } + } + } +} diff --git a/Foxnouns.Frontend/src/lib/api/error.ts b/Foxnouns.Frontend/src/lib/api/error.ts index 6b5d918..eb93884 100644 --- a/Foxnouns.Frontend/src/lib/api/error.ts +++ b/Foxnouns.Frontend/src/lib/api/error.ts @@ -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; +}; diff --git a/Foxnouns.Frontend/src/lib/components/Avatar.svelte b/Foxnouns.Frontend/src/lib/components/Avatar.svelte index 1b116ea..99a5608 100644 --- a/Foxnouns.Frontend/src/lib/components/Avatar.svelte +++ b/Foxnouns.Frontend/src/lib/components/Avatar.svelte @@ -1,14 +1,14 @@ diff --git a/Foxnouns.Frontend/src/lib/components/Error.svelte b/Foxnouns.Frontend/src/lib/components/Error.svelte index 9ca2ff0..09337a9 100644 --- a/Foxnouns.Frontend/src/lib/components/Error.svelte +++ b/Foxnouns.Frontend/src/lib/components/Error.svelte @@ -4,17 +4,19 @@ 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(); - - {#if error.code === ErrorCode.BadRequest} - {$t("error.bad-request-header")} - {:else} - {$t("error.generic-header")} - {/if} - +{#if showHeader !== false} + + {#if error.code === ErrorCode.BadRequest} + {$t("error.bad-request-header")} + {:else} + {$t("error.generic-header")} + {/if} + +{/if}

{errorDescription($t, error.code)}

{#if error.errors}
diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index 5d603d7..abc4d85 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -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" } diff --git a/Foxnouns.Frontend/src/lib/index.ts b/Foxnouns.Frontend/src/lib/index.ts index 6b65464..7105327 100644 --- a/Foxnouns.Frontend/src/lib/index.ts +++ b/Foxnouns.Frontend/src/lib/index.ts @@ -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); diff --git a/Foxnouns.Frontend/src/routes/+layout.server.ts b/Foxnouns.Frontend/src/routes/+layout.server.ts index e73ca7d..00c3ef3 100644 --- a/Foxnouns.Frontend/src/routes/+layout.server.ts +++ b/Foxnouns.Frontend/src/routes/+layout.server.ts @@ -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("GET", "/users/@me", { fetch, cookies }); + meUser = await apiRequest("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); diff --git a/Foxnouns.Frontend/src/routes/settings/+layout.server.ts b/Foxnouns.Frontend/src/routes/settings/+layout.server.ts new file mode 100644 index 0000000..a1ac93c --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/+layout.server.ts @@ -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! }; +}; diff --git a/Foxnouns.Frontend/src/routes/settings/+layout.svelte b/Foxnouns.Frontend/src/routes/settings/+layout.svelte new file mode 100644 index 0000000..0b0ac53 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/+layout.svelte @@ -0,0 +1,44 @@ + + + + {$t("title.settings")} • pronouns.cc + + +
+ + + {@render children?.()} +
diff --git a/Foxnouns.Frontend/src/routes/settings/+page.server.ts b/Foxnouns.Frontend/src/routes/settings/+page.server.ts new file mode 100644 index 0000000..9e35bda --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/+page.server.ts @@ -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; + } + }, +}; diff --git a/Foxnouns.Frontend/src/routes/settings/+page.svelte b/Foxnouns.Frontend/src/routes/settings/+page.svelte new file mode 100644 index 0000000..062d9e6 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/+page.svelte @@ -0,0 +1,113 @@ + + +

{$t("settings.general-information-tab")}

+ +
+
+
Change your username
+
+ + + + + + + {#if form?.ok} +

+ Successfully changed your username! +

+ {:else if usernameError} +

+ + {$t("settings.username-update-error", { message: usernameError.message })} +

+ {:else if form?.error} + + {/if} + +

+ + {$t("settings.username-change-hint")} +

+
+ +
+ +
+

{$t("settings.log-out-title")}

+

{$t("settings.log-out-hint")}

+
+ +
+
+ +
+

{$t("settings.force-log-out-title")}

+

{$t("settings.force-log-out-hint")}

+ {$t("settings.force-log-out-button")} +
+ +
+

{$t("settings.table-title")}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{$t("settings.table-id")} + {data.user.id} +
{$t("settings.table-created-at")}{createdAt.toLocaleString(DateTime.DATETIME_MED)}
{$t("settings.table-member-count")} + {data.user.members.length}/{data.meta.limits.member_count} +
{$t("settings.table-member-list-hidden")}{data.user.member_list_hidden ? $t("yes") : $t("no")}
{$t("settings.table-custom-preferences")} + {Object.keys(data.user.custom_preferences).length}/{data.meta.limits.custom_preferences} +
{$t("settings.table-role")} + {data.user.role} +
+