Compare commits

..

4 commits

25 changed files with 707 additions and 159 deletions

View file

@ -164,6 +164,9 @@ public class MembersController(
member.Links = req.Links ?? []; member.Links = req.Links ?? [];
} }
if (req.HasProperty(nameof(req.Unlisted)))
member.Unlisted = req.Unlisted ?? false;
if (req.Names != null) if (req.Names != null)
{ {
errors.AddRange( errors.AddRange(
@ -244,6 +247,7 @@ public class MembersController(
public Pronoun[]? Pronouns { get; init; } public Pronoun[]? Pronouns { get; init; }
public Field[]? Fields { get; init; } public Field[]? Fields { get; init; }
public Snowflake[]? Flags { get; init; } public Snowflake[]? Flags { get; init; }
public bool? Unlisted { get; init; }
} }
[HttpDelete("/api/v2/users/@me/members/{memberRef}")] [HttpDelete("/api/v2/users/@me/members/{memberRef}")]

View file

@ -414,7 +414,7 @@ public static partial class ValidationUtils
case > Limits.FieldEntryTextLimit: case > Limits.FieldEntryTextLimit:
errors.Add( errors.Add(
( (
$"{errorPrefix}.{entryIdx}.value", $"{errorPrefix}.{entryIdx}.display_text",
ValidationError.LengthError( ValidationError.LengthError(
"Pronoun display text is too long", "Pronoun display text is too long",
1, 1,
@ -427,7 +427,7 @@ public static partial class ValidationUtils
case < 1: case < 1:
errors.Add( errors.Add(
( (
$"{errorPrefix}.{entryIdx}.value", $"{errorPrefix}.{entryIdx}.display_text",
ValidationError.LengthError( ValidationError.LengthError(
"Pronoun display text is too short", "Pronoun display text is too short",
1, 1,

View file

@ -44,6 +44,8 @@
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"sanitize-html": "^2.13.1", "sanitize-html": "^2.13.1",
"svelte-tippy": "^1.3.2",
"tippy.js": "^6.3.7",
"tslog": "^4.9.3" "tslog": "^4.9.3"
} }
} }

View file

@ -29,6 +29,12 @@ importers:
sanitize-html: sanitize-html:
specifier: ^2.13.1 specifier: ^2.13.1
version: 2.13.1 version: 2.13.1
svelte-tippy:
specifier: ^1.3.2
version: 1.3.2
tippy.js:
specifier: ^6.3.7
version: 6.3.7
tslog: tslog:
specifier: ^4.9.3 specifier: ^4.9.3
version: 4.9.3 version: 4.9.3
@ -1325,6 +1331,9 @@ packages:
svelte: svelte:
optional: true optional: true
svelte-tippy@1.3.2:
resolution: {integrity: sha512-41f+85hwhKBRqX0UNYrgFsi34Kk/KDvUkIZXYANxkWoA2NTVTCZbUC2J8hRNZ4TRVxObTshoZRjK2co5+i6LMw==}
svelte@5.2.2: svelte@5.2.2:
resolution: {integrity: sha512-eHIJRcvA6iuXdRGMESTmBtWTQCcCiol4gyH9DA60ybS35W1x27cvtbndNvWDqX72blyf+AYeQ4gzZ0XGg3L8sw==} resolution: {integrity: sha512-eHIJRcvA6iuXdRGMESTmBtWTQCcCiol4gyH9DA60ybS35W1x27cvtbndNvWDqX72blyf+AYeQ4gzZ0XGg3L8sw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1337,6 +1346,9 @@ packages:
tiny-glob@0.2.9: tiny-glob@0.2.9:
resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==}
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@ -2565,6 +2577,10 @@ snapshots:
optionalDependencies: optionalDependencies:
svelte: 5.2.2 svelte: 5.2.2
svelte-tippy@1.3.2:
dependencies:
tippy.js: 6.3.7
svelte@5.2.2: svelte@5.2.2:
dependencies: dependencies:
'@ampproject/remapping': 2.3.0 '@ampproject/remapping': 2.3.0
@ -2592,6 +2608,10 @@ snapshots:
globalyzer: 0.1.0 globalyzer: 0.1.0
globrex: 0.1.2 globrex: 0.1.2
tippy.js@6.3.7:
dependencies:
'@popperjs/core': 2.11.8
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0

View file

@ -29,6 +29,15 @@
white-space: pre-line; white-space: pre-line;
} }
// Make tippy tooltips look like bootstrap's
.tippy-box {
padding: bootstrap.$spacer * 0.25 bootstrap.$spacer * 0.5;
opacity: 0.9;
color: var(--bs-body-bg);
background-color: var(--bs-emphasis-color);
border-radius: var(--bs-border-radius);
}
// Add breakpoint-dependent w-{size} utilities // Add breakpoint-dependent w-{size} utilities
// Source: https://stackoverflow.com/questions/47760132/any-way-to-get-breakpoint-specific-width-classes // Source: https://stackoverflow.com/questions/47760132/any-way-to-get-breakpoint-specific-width-classes
@each $breakpoint in map-keys(bootstrap.$grid-breakpoints) { @each $breakpoint in map-keys(bootstrap.$grid-breakpoints) {

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ErrorCode, type RawApiError } from "$api/error"; import { ErrorCode, type RawApiError } from "$api/error";
import errorDescription from "$lib/errorCodes.svelte"; import errorDescription from "$lib/errorCodes";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import KeyedValidationErrors from "./errors/KeyedValidationErrors.svelte"; import KeyedValidationErrors from "./errors/KeyedValidationErrors.svelte";

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { Icon } from "@sveltestrap/sveltestrap";
import type { MouseEventHandler } from "svelte/elements";
import tippy from "$lib/tippy";
type Props = {
icon: string;
tooltip: string;
color?: "primary" | "secondary" | "success" | "danger";
type?: "submit" | "reset" | "button";
id?: string;
onclick?: MouseEventHandler<HTMLButtonElement>;
};
let { icon, tooltip, color = "primary", type, id, onclick }: Props = $props();
</script>
<button {id} {type} use:tippy={{ content: tooltip }} class="btn btn-{color}" {onclick}>
<Icon name={icon} />
<span class="visually-hidden">{tooltip}</span>
</button>

View file

@ -1,17 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Icon, Tooltip } from "@sveltestrap/sveltestrap"; import { Icon } from "@sveltestrap/sveltestrap";
import type { CustomPreference } from "$api/models/user"; import type { CustomPreference } from "$api/models/user";
import tippy from "$lib/tippy";
type Props = { preference: CustomPreference }; type Props = { preference: CustomPreference };
let { preference }: Props = $props(); let { preference }: Props = $props();
// svelte-ignore non_reactive_update
let elem: HTMLSpanElement;
</script> </script>
<span bind:this={elem} aria-hidden={true}> <span use:tippy={{ content: preference.tooltip }} aria-hidden={true}>
<Icon name={preference.icon} /> <Icon name={preference.icon} />
</span> </span>
<span class="visually-hidden">{preference.tooltip}:</span> <span class="visually-hidden">{preference.tooltip}:</span>
<Tooltip aria-hidden target={elem} placement="top">{preference.tooltip}</Tooltip>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import type { CustomPreference, FieldEntry } from "$api/models";
import IconButton from "$components/IconButton.svelte";
import { t } from "$lib/i18n";
import FieldEntryEditor from "./FieldEntryEditor.svelte";
type Props = {
name: string;
entries: FieldEntry[];
allPreferences: Record<string, CustomPreference>;
};
let { name, entries = $bindable(), allPreferences }: Props = $props();
let newEntry = $state("");
const moveValue = (index: number, up: boolean) => {
if (up && index == 0) return;
if (!up && index == entries.length - 1) return;
const newIndex = up ? index - 1 : index + 1;
const temp = entries[index];
entries[index] = entries[newIndex];
entries[newIndex] = temp;
entries = [...entries];
};
const removeValue = (index: number) => {
entries.splice(index, 1);
entries = [...entries];
};
const addEntry = (event: Event) => {
event.preventDefault();
if (!newEntry) return;
entries = [...entries, { value: newEntry, status: "okay" }];
newEntry = "";
};
</script>
<h4>{name}</h4>
{#each entries as _, index}
<FieldEntryEditor
{index}
bind:value={entries[index]}
{allPreferences}
{moveValue}
{removeValue}
/>
{/each}
<form class="input-group m-1" onsubmit={addEntry}>
<input type="text" class="form-control" bind:value={newEntry} />
<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-entry")} />
</form>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { defaultPreferences, type CustomPreference, type FieldEntry } from "$api/models";
import IconButton from "$components/IconButton.svelte";
import { t } from "$lib/i18n";
import tippy from "$lib/tippy";
import {
ButtonDropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
Icon,
} from "@sveltestrap/sveltestrap";
type Props = {
index: number;
value: FieldEntry;
allPreferences: Record<string, CustomPreference>;
moveValue: (index: number, up: boolean) => void;
removeValue: (index: number) => void;
};
let { index, value = $bindable(), allPreferences, moveValue, removeValue }: Props = $props();
let status = $derived(
value.status in allPreferences ? allPreferences[value.status] : defaultPreferences.missing,
);
let prefIds = $derived(Object.keys(allPreferences).filter((s) => s !== "missing"));
</script>
<div class="input-group m-1">
<IconButton
icon="chevron-up"
color="secondary"
tooltip={$t("editor.move-entry-up")}
onclick={() => moveValue(index, true)}
/>
<IconButton
icon="chevron-down"
color="secondary"
tooltip={$t("editor.move-entry-down")}
onclick={() => moveValue(index, true)}
/>
<input type="text" class="form-control" bind:value={value.value} />
<ButtonDropdown>
<span use:tippy={{ content: status.tooltip }}>
<DropdownToggle color="secondary" caret>
<Icon name={status.icon} />
</DropdownToggle>
</span>
<DropdownMenu>
{#each prefIds as id}
<DropdownItem on:click={() => (value.status = id)} active={value.status === id}>
<Icon name={allPreferences[id].icon} aria-hidden />
{allPreferences[id].tooltip}
</DropdownItem>
{/each}
</DropdownMenu>
</ButtonDropdown>
<IconButton
color="danger"
icon="trash3"
tooltip={$t("editor.remove-entry")}
onclick={() => removeValue(index)}
/>
</div>

View file

@ -0,0 +1,100 @@
<script lang="ts">
import { defaultPreferences, type CustomPreference, type Pronoun } from "$api/models";
import IconButton from "$components/IconButton.svelte";
import { t } from "$lib/i18n";
import tippy from "$lib/tippy";
import {
ButtonDropdown,
Collapse,
DropdownItem,
DropdownMenu,
DropdownToggle,
Icon,
InputGroupText,
Popover,
} from "@sveltestrap/sveltestrap";
type Props = {
index: number;
value: Pronoun;
allPreferences: Record<string, CustomPreference>;
moveValue: (index: number, up: boolean) => void;
removeValue: (index: number) => void;
};
let { index, value = $bindable(), allPreferences, moveValue, removeValue }: Props = $props();
$effect(() => {
if (!value.display_text) value.display_text = null;
});
let status = $derived(
value.status in allPreferences ? allPreferences[value.status] : defaultPreferences.missing,
);
let prefIds = $derived(Object.keys(allPreferences).filter((s) => s !== "missing"));
let displayOpen = $state(false);
</script>
<div class="m-1">
<div class="input-group">
<IconButton
icon="chevron-up"
color="secondary"
tooltip={$t("editor.move-entry-up")}
onclick={() => moveValue(index, true)}
/>
<IconButton
icon="chevron-down"
color="secondary"
tooltip={$t("editor.move-entry-down")}
onclick={() => moveValue(index, true)}
/>
<input type="text" class="form-control" bind:value={value.value} />
<ButtonDropdown>
<span use:tippy={{ content: status.tooltip }}>
<DropdownToggle color="secondary" caret>
<Icon name={status.icon} />
</DropdownToggle>
</span>
<DropdownMenu>
{#each prefIds as id}
<DropdownItem on:click={() => (value.status = id)} active={value.status === id}>
<Icon name={allPreferences[id].icon} aria-hidden />
{allPreferences[id].tooltip}
</DropdownItem>
{/each}
</DropdownMenu>
</ButtonDropdown>
<IconButton
color="secondary"
icon={value.display_text ? "tag-fill" : "tag"}
tooltip={$t("editor.change-display-text")}
onclick={() => (displayOpen = !displayOpen)}
/>
<IconButton
color="danger"
icon="trash3"
tooltip={$t("editor.remove-entry")}
onclick={() => removeValue(index)}
/>
</div>
<Collapse class="mt-1" isOpen={displayOpen}>
<div class="input-group">
<InputGroupText>{$t("editor.display-text-label")}</InputGroupText>
<input
placeholder={$t("editor.display-text-example")}
type="text"
class="form-control"
bind:value={value.display_text}
/>
<IconButton id="display-help" icon="question" tooltip="Help" color="secondary" />
<!-- TODO: remove children={false} once sveltestrap is updated
This component is too complex to write manually, sadly -->
<Popover target="display-help" placement="bottom" children={false}>
{$t("editor.display-text-info")}
</Popover>
</div>
</Collapse>
</div>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import type { CustomPreference, Pronoun } from "$api/models";
import IconButton from "$components/IconButton.svelte";
import { PUBLIC_LANGUAGE } from "$env/static/public";
import defaultPronouns from "$lib/defaultPronouns";
import { t } from "$lib/i18n";
import PronounEntryEditor from "./PronounEntryEditor.svelte";
type Props = {
entries: Pronoun[];
allPreferences: Record<string, CustomPreference>;
};
let { entries = $bindable(), allPreferences }: Props = $props();
let newEntry = $state("");
const moveValue = (index: number, up: boolean) => {
if (up && index == 0) return;
if (!up && index == entries.length - 1) return;
const newIndex = up ? index - 1 : index + 1;
const temp = entries[index];
entries[index] = entries[newIndex];
entries[newIndex] = temp;
entries = [...entries];
};
const removeValue = (index: number) => {
entries.splice(index, 1);
entries = [...entries];
};
const addEntry = (event: Event) => {
event.preventDefault();
if (!newEntry) return;
// Some pronouns are commonly abbreviated and should be expanded by the editor, or people will get confused
let entry = { value: newEntry, display_text: null as string | null, status: "okay" };
if (PUBLIC_LANGUAGE in defaultPronouns && newEntry in defaultPronouns[PUBLIC_LANGUAGE]) {
const def = defaultPronouns[PUBLIC_LANGUAGE][newEntry];
if (def.pronouns) entry.value = def.pronouns.join("/");
if (def.display) entry.display_text = def.display;
}
entries = [...entries, entry];
newEntry = "";
};
</script>
<h4>{$t("profile.pronouns-header")}</h4>
{#each entries as _, index}
<PronounEntryEditor
{index}
bind:value={entries[index]}
{allPreferences}
{moveValue}
{removeValue}
/>
{/each}
<form class="input-group m-1" onsubmit={addEntry}>
<input type="text" class="form-control" bind:value={newEntry} />
<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-entry")} />
</form>

View file

@ -1,17 +1,18 @@
<script lang="ts"> <script lang="ts">
import type { PrideFlag } from "$api/models/user"; import type { PrideFlag } from "$api/models/user";
import { Tooltip } from "@sveltestrap/sveltestrap"; import tippy from "$lib/tippy";
type Props = { flag: PrideFlag }; type Props = { flag: PrideFlag };
let { flag }: Props = $props(); let { flag }: Props = $props();
// svelte-ignore non_reactive_update
let elem: HTMLImageElement;
</script> </script>
<span class="mx-2 my-1"> <span class="mx-2 my-1">
<Tooltip target={elem} aria-hidden placement="top">{flag.description ?? flag.name}</Tooltip> <img
<img bind:this={elem} class="flag" src={flag.image_url} alt={flag.description ?? flag.name} /> use:tippy={{ content: flag.description ?? flag.name }}
class="flag"
src={flag.image_url}
alt={flag.description ?? flag.name}
/>
{flag.name} {flag.name}
</span> </span>

View file

@ -0,0 +1,16 @@
const enPronouns = {
"they/them": { pronouns: ["they", "them", "their", "theirs", "themself"] },
"they/them (singular)": {
pronouns: ["they", "them", "their", "theirs", "themself"],
display: "they/them (singular)",
},
"they/them (plural)": {
pronouns: ["they", "them", "their", "theirs", "themselves"],
display: "they/them (plural)",
},
"he/him": { pronouns: ["he", "him", "his", "his", "himself"] },
"she/her": { pronouns: ["she", "her", "her", "hers", "herself"] },
"it/its": { pronouns: ["it", "it", "its", "its", "itself"], display: "it/its" },
} as Record<string, { pronouns: string[]; display?: string }>;
export default enPronouns;

View file

@ -0,0 +1,7 @@
import enPronouns from "./en";
const defaultPronouns = {
en: enPronouns,
} as Record<string, Record<string, { pronouns: string[]; display?: string }>>;
export default defaultPronouns;

View file

@ -1,136 +1,150 @@
{ {
"hello": "Hello, {{name}}!", "hello": "Hello, {{name}}!",
"nav": { "nav": {
"log-in": "Log in or sign up", "log-in": "Log in or sign up",
"settings": "Settings" "settings": "Settings"
}, },
"avatar-tooltip": "Avatar for {{name}}", "avatar-tooltip": "Avatar for {{name}}",
"profile": { "profile": {
"edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.", "edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.",
"edit-user-profile-notice": "You are currently viewing your public profile.", "edit-user-profile-notice": "You are currently viewing your public profile.",
"edit-profile-link": "Edit profile", "edit-profile-link": "Edit profile",
"names-header": "Names", "names-header": "Names",
"pronouns-header": "Pronouns", "pronouns-header": "Pronouns",
"default-members-header": "Members", "default-members-header": "Members",
"create-member-button": "Create member", "create-member-button": "Create member",
"back-to-user": "Back to {{name}}" "back-to-user": "Back to {{name}}"
}, },
"title": { "title": {
"log-in": "Log in", "log-in": "Log in",
"welcome": "Welcome", "welcome": "Welcome",
"settings": "Settings" "settings": "Settings"
}, },
"auth": { "auth": {
"log-in-form-title": "Log in with email", "log-in-form-title": "Log in with email",
"log-in-form-email-label": "Email address", "log-in-form-email-label": "Email address",
"log-in-form-password-label": "Password", "log-in-form-password-label": "Password",
"register-with-email-button": "Register with email", "register-with-email-button": "Register with email",
"log-in-button": "Log in", "log-in-button": "Log in",
"log-in-3rd-party-header": "Log in with another service", "log-in-3rd-party-header": "Log in with another service",
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:", "log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
"log-in-with-discord": "Log in with Discord", "log-in-with-discord": "Log in with Discord",
"log-in-with-google": "Log in with Google", "log-in-with-google": "Log in with Google",
"log-in-with-tumblr": "Log in with Tumblr", "log-in-with-tumblr": "Log in with Tumblr",
"log-in-with-the-fediverse": "Log in with the Fediverse", "log-in-with-the-fediverse": "Log in with the Fediverse",
"remote-fediverse-account-label": "Your Fediverse account", "remote-fediverse-account-label": "Your Fediverse account",
"register-username-label": "Username", "register-username-label": "Username",
"register-button": "Register account", "register-button": "Register account",
"register-with-mastodon": "Register with a Fediverse account", "register-with-mastodon": "Register with a Fediverse account",
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?", "log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end" "log-in-with-fediverse-force-refresh-button": "Force a refresh on our end"
}, },
"error": { "error": {
"bad-request-header": "Something was wrong with your input", "bad-request-header": "Something was wrong with your input",
"generic-header": "Something went wrong", "generic-header": "Something went wrong",
"raw-header": "Raw error", "raw-header": "Raw error",
"authentication-error": "Something went wrong when logging you in.", "authentication-error": "Something went wrong when logging you in.",
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.", "bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
"forbidden": "You are not allowed to perform that action.", "forbidden": "You are not allowed to perform that action.",
"internal-server-error": "Server experienced an internal error, please try again later.", "internal-server-error": "Server experienced an internal error, please try again later.",
"authentication-required": "You need to log in first.", "authentication-required": "You need to log in first.",
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?", "missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
"generic-error": "An unknown error occurred.", "generic-error": "An unknown error occurred.",
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.", "user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
"member-not-found": "Member not found, please check your spelling and try again.", "member-not-found": "Member not found, please check your spelling and try again.",
"account-already-linked": "This account is already linked with a pronouns.cc account.", "account-already-linked": "This account is already linked with a pronouns.cc account.",
"last-auth-method": "You cannot remove your last authentication method.", "last-auth-method": "You cannot remove your last authentication method.",
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.", "validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
"validation-min-length-error": "Value is too long, minimum length is {{min}}, current length is {{actual}}.", "validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.",
"validation-disallowed-value-1": "The following value is not allowed here", "validation-disallowed-value-1": "The following value is not allowed here",
"validation-disallowed-value-2": "Allowed values are", "validation-disallowed-value-2": "Allowed values are",
"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",
"noscript-title": "This page requires JavaScript", "noscript-title": "This page requires JavaScript",
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.", "noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
"noscript-short": "Requires JavaScript" "noscript-short": "Requires JavaScript"
}, },
"settings": { "settings": {
"general-information-tab": "General information", "general-information-tab": "General information",
"your-profile-tab": "Your profile", "your-profile-tab": "Your profile",
"members-tab": "Members", "members-tab": "Members",
"authentication-tab": "Authentication", "authentication-tab": "Authentication",
"export-tab": "Export your data", "export-tab": "Export your data",
"change-username-button": "Change username", "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-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}}", "username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
"change-avatar-link": "Change your avatar here", "change-avatar-link": "Change your avatar here",
"new-username": "New username", "new-username": "New username",
"table-role": "Role", "table-role": "Role",
"table-custom-preferences": "Custom preferences", "table-custom-preferences": "Custom preferences",
"table-member-list-hidden": "Member list hidden?", "table-member-list-hidden": "Member list hidden?",
"table-member-count": "Member count", "table-member-count": "Member count",
"table-created-at": "Account created at", "table-created-at": "Account created at",
"table-id": "Your ID", "table-id": "Your ID",
"table-title": "Account information", "table-title": "Account information",
"force-log-out-title": "Log out everywhere", "force-log-out-title": "Log out everywhere",
"force-log-out-button": "Force log out", "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.", "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-title": "Log out",
"log-out-hint": "Use this button to log out on this device only.", "log-out-hint": "Use this button to log out on this device only.",
"log-out-button": "Log out", "log-out-button": "Log out",
"avatar": "Avatar", "avatar": "Avatar",
"username-update-success": "Successfully changed your username!", "username-update-success": "Successfully changed your username!",
"create-member-title": "Create a new member", "create-member-title": "Create a new member",
"create-member-name-label": "Member name" "create-member-name-label": "Member name"
}, },
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",
"edit-profile": { "edit-profile": {
"user-header": "Editing your profile", "user-header": "Editing your profile",
"general-tab": "General", "general-tab": "General",
"names-pronouns-tab": "Names & pronouns", "names-pronouns-tab": "Names & pronouns",
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})", "file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
"sid-current": "Current short ID:", "sid-current": "Current short ID:",
"sid": "Short ID", "sid": "Short ID",
"sid-reroll": "Reroll short ID", "sid-reroll": "Reroll short ID",
"sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.", "sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.",
"sid-copy": "Copy short link", "sid-copy": "Copy short link",
"update-avatar": "Update avatar", "update-avatar": "Update avatar",
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.", "avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
"member-header-label": "\"Members\" header text", "member-header-label": "\"Members\" header text",
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.", "member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
"hide-member-list-label": "Hide member list", "hide-member-list-label": "Hide member list",
"timezone-label": "Timezone", "timezone-label": "Timezone",
"timezone-preview": "This will show up on your profile like this:", "timezone-preview": "This will show up on your profile like this:",
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.", "timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
"hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.", "hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.",
"profile-options-header": "Profile options", "profile-options-header": "Profile options",
"bio-tab": "Bio", "bio-tab": "Bio",
"saved-changes": "Successfully saved changes!", "saved-changes": "Successfully saved changes!",
"bio-length-hint": "Using {{length}}/{{maxLength}} characters", "bio-length-hint": "Using {{length}}/{{maxLength}} characters",
"preview": "Preview", "preview": "Preview",
"fields-tab": "Fields", "fields-tab": "Fields",
"flags-links-tab": "Flags & links", "flags-links-tab": "Flags & links",
"back-to-settings-tab": "Back to settings", "back-to-settings-tab": "Back to settings",
"member-header": "Editing member {{name}}", "member-header": "Editing profile of {{name}}",
"username": "Username", "username": "Username",
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.", "change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.",
"change-username-link": "Go to settings", "change-username-link": "Go to settings",
"member-name": "Name", "member-name": "Name",
"change-member-name": "Change name", "change-member-name": "Change name",
"display-name": "Display name" "display-name": "Display name",
}, "unlisted-label": "Hide from member list",
"save-changes": "Save changes", "unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
"change": "Change" "edit-names-pronouns-header": "Edit names and pronouns",
"back-to-profile-tab": "Back to profile"
},
"save-changes": "Save changes",
"change": "Change",
"editor": {
"remove-entry": "Remove entry",
"move-entry-down": "Move entry down",
"move-entry-up": "Move entry up",
"add-entry": "Add entry",
"change-display-text": "Change display text",
"display-text-example": "Optional display text (e.g. it/its)",
"display-text-label": "Display text",
"display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set."
}
} }

View file

@ -0,0 +1,11 @@
import "tippy.js/animations/scale-subtle.css";
import { createTippy } from "svelte-tippy";
// use with use:tippy on elements
// temporary (probably) until sveltestrap works with svelte 5
const tippy = createTippy({
animation: "scale-subtle",
delay: [null, 0],
});
export default tippy;

View file

@ -25,7 +25,7 @@
<OwnProfileNotice editLink="/settings/profile" /> <OwnProfileNotice editLink="/settings/profile" />
{/if} {/if}
<ProfileHeader name="@{data.user.username}" profile={data.user}/> <ProfileHeader name="@{data.user.username}" profile={data.user} />
<ProfileFields profile={data.user} {allPreferences} /> <ProfileFields profile={data.user} {allPreferences} />
{#if data.members.length > 0} {#if data.members.length > 0}
@ -33,7 +33,7 @@
<h2> <h2>
{data.user.member_title || $t("profile.default-members-header")} {data.user.member_title || $t("profile.default-members-header")}
{#if isMeUser} {#if isMeUser}
<a class="btn btn-success" href="/settings/create-member"> <a class="btn btn-success" href="/settings/members/new">
<Icon name="person-plus-fill" aria-hidden /> <Icon name="person-plus-fill" aria-hidden />
{$t("profile.create-member-button")} {$t("profile.create-member-button")}
</a> </a>

View file

@ -8,14 +8,20 @@
let { data, children }: Props = $props(); let { data, children }: Props = $props();
const isActive = (path: string) => $page.url.pathname === path; const isActive = (path: string) => $page.url.pathname === path;
let name = $derived(
data.member.display_name === data.member.name
? data.member.name
: `${data.member.display_name} (${data.member.name})`,
);
</script> </script>
<svelte:head> <svelte:head>
<title>{$t("edit-profile.member-header", { name: data.member.name })} • pronouns.cc</title> <title>{$t("edit-profile.member-header", { name })} • pronouns.cc</title>
</svelte:head> </svelte:head>
<div class="container"> <div class="container">
<h3>{$t("edit-profile.member-header", { name: data.member.name })}</h3> <h3>{$t("edit-profile.member-header", { name })}</h3>
<div class="row"> <div class="row">
<div class="col-md-3 mt-1 mb-3"> <div class="col-md-3 mt-1 mb-3">
<div class="list-group"> <div class="list-group">
@ -51,7 +57,7 @@
href="/@{data.user.username}/{data.member.name}" href="/@{data.user.username}/{data.member.name}"
class="list-group-item list-group-item-action text-danger" class="list-group-item list-group-item-action text-danger"
> >
Back to member {$t("edit-profile.back-to-profile-tab")}
</a> </a>
<a href="/settings/members" class="list-group-item list-group-item-action text-danger"> <a href="/settings/members" class="list-group-item list-group-item-action text-danger">
{$t("edit-profile.back-to-settings-tab")} {$t("edit-profile.back-to-settings-tab")}

View file

@ -64,7 +64,8 @@ export const actions = {
}, },
bio: async ({ params, request, fetch, cookies }) => { bio: async ({ params, request, fetch, cookies }) => {
const body = await request.formData(); const body = await request.formData();
const bio = body.get("bio") as string | null; let bio = body.get("bio") as string | null;
if (!bio || bio === "") bio = null;
try { try {
await fastRequest("PATCH", `/users/@me/members/${params.id}`, { await fastRequest("PATCH", `/users/@me/members/${params.id}`, {
@ -79,4 +80,21 @@ export const actions = {
throw e; throw e;
} }
}, },
options: async ({ params, request, fetch, cookies }) => {
const body = await request.formData();
let unlisted = !!body.get("unlisted");
try {
await fastRequest("PATCH", `/users/@me/members/${params.id}`, {
body: { unlisted },
fetch,
cookies,
});
return { error: null, ok: true };
} catch (e) {
if (e instanceof ApiError) return { error: e.obj, ok: false };
log.error("Error patching member %s:", params.id, e);
throw e;
}
},
}; };

View file

@ -5,7 +5,7 @@
import { apiRequest, fastRequest } from "$api"; import { apiRequest, fastRequest } from "$api";
import ApiError from "$api/error"; import ApiError from "$api/error";
import log from "$lib/log"; import log from "$lib/log";
import { InputGroup } from "@sveltestrap/sveltestrap"; import { Icon, InputGroup } from "@sveltestrap/sveltestrap";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import AvatarEditor from "$components/editor/AvatarEditor.svelte"; import AvatarEditor from "$components/editor/AvatarEditor.svelte";
import ErrorAlert from "$components/ErrorAlert.svelte"; import ErrorAlert from "$components/ErrorAlert.svelte";
@ -13,6 +13,7 @@
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import SidEditor from "$components/editor/SidEditor.svelte"; import SidEditor from "$components/editor/SidEditor.svelte";
import BioEditor from "$components/editor/BioEditor.svelte"; import BioEditor from "$components/editor/BioEditor.svelte";
import { PUBLIC_BASE_URL } from "$env/static/public";
type Props = { data: PageData; form: ActionData }; type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props(); let { data, form }: Props = $props();
@ -106,7 +107,36 @@
<h4>{$t("edit-profile.sid")}</h4> <h4>{$t("edit-profile.sid")}</h4>
<SidEditor {rerollSid} {sid} {canRerollSid} /> <SidEditor {rerollSid} {sid} {canRerollSid} />
</div> </div>
<div class="row"> <div class="row mb-3">
<h4>{$t("edit-profile.profile-options-header")}</h4>
<form method="POST" action="?/options">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
checked={data.member.unlisted}
value="true"
name="unlisted"
id="unlisted"
/>
<label class="form-check-label" for="unlisted">
{$t("edit-profile.unlisted-label")}
</label>
</div>
<p class="text-muted mt-1">
<Icon name="info-circle-fill" aria-hidden />
{$t("edit-profile.unlisted-note")}
<code>
{PUBLIC_BASE_URL.substring("https://".length)}/@{data.member.user.username}/{data.member
.name}
</code>
</p>
<div class="mt-2">
<button type="submit" class="btn btn-primary">{$t("save-changes")}</button>
</div>
</form>
</div>
<div class="row mb-3">
<h4>{$t("edit-profile.bio-tab")}</h4> <h4>{$t("edit-profile.bio-tab")}</h4>
<form method="POST" action="?/bio"> <form method="POST" action="?/bio">
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} /> <BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />

View file

@ -0,0 +1,52 @@
<script lang="ts">
import { apiRequest } from "$api";
import ApiError, { type RawApiError } from "$api/error";
import type { Member } from "$api/models";
import { mergePreferences } from "$api/models/user";
import FieldEditor from "$components/editor/FieldEditor.svelte";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import PronounsEditor from "$components/editor/PronounsEditor.svelte";
import { t } from "$lib/i18n";
import log from "$lib/log";
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
let names = $state(data.member.names);
let pronouns = $state(data.member.pronouns);
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
let allPreferences = $derived(mergePreferences(data.user.custom_preferences));
const update = async () => {
try {
const resp = await apiRequest<Member>("PATCH", `/users/@me/members/${data.member.id}`, {
body: { names, pronouns },
token: data.token,
});
names = resp.names;
pronouns = resp.pronouns;
ok = { ok: true, error: null };
} catch (e) {
ok = { ok: false, error: null };
log.error("Could not update names/pronouns:", e);
if (e instanceof ApiError) ok.error = e.obj;
}
};
</script>
<FormStatusMarker form={ok} />
<div>
<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} />
</div>
<div>
<PronounsEditor bind:entries={pronouns} {allPreferences} />
</div>
<div>
<button class="btn btn-primary" onclick={() => update()}>{$t("save-changes")}</button>
</div>

View file

@ -2,9 +2,10 @@
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import type { LayoutData } from "./$types";
type Props = { children: Snippet }; type Props = { data: LayoutData; children: Snippet };
let { children }: Props = $props(); let { data, children }: Props = $props();
const isActive = (path: string) => $page.url.pathname === path; const isActive = (path: string) => $page.url.pathname === path;
</script> </script>
@ -53,6 +54,9 @@
> >
{$t("edit-profile.flags-links-tab")} {$t("edit-profile.flags-links-tab")}
</a> </a>
<a href="/@{data.user.username}" class="list-group-item list-group-item-action text-danger">
{$t("edit-profile.back-to-profile-tab")}
</a>
<a href="/settings" class="list-group-item list-group-item-action text-danger"> <a href="/settings" class="list-group-item list-group-item-action text-danger">
{$t("edit-profile.back-to-settings-tab")} {$t("edit-profile.back-to-settings-tab")}
</a> </a>

View file

@ -0,0 +1,51 @@
<script lang="ts">
import { apiRequest } from "$api";
import ApiError, { type RawApiError } from "$api/error";
import { mergePreferences, type User } from "$api/models/user";
import FieldEditor from "$components/editor/FieldEditor.svelte";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import PronounsEditor from "$components/editor/PronounsEditor.svelte";
import { t } from "$lib/i18n";
import log from "$lib/log";
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
let names = $state(data.user.names);
let pronouns = $state(data.user.pronouns);
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
let allPreferences = $derived(mergePreferences(data.user.custom_preferences));
const update = async () => {
try {
const resp = await apiRequest<User>("PATCH", "/users/@me", {
body: { names, pronouns },
token: data.token,
});
names = resp.names;
pronouns = resp.pronouns;
ok = { ok: true, error: null };
} catch (e) {
ok = { ok: false, error: null };
log.error("Could not update names/pronouns:", e);
if (e instanceof ApiError) ok.error = e.obj;
}
};
</script>
<FormStatusMarker form={ok} />
<div>
<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} />
</div>
<div>
<PronounsEditor bind:entries={pronouns} {allPreferences} />
</div>
<div>
<button class="btn btn-primary" onclick={() => update()}>{$t("save-changes")}</button>
</div>