feat(frontend): edit names/pronouns

This commit is contained in:
sam 2024-11-25 23:07:17 +01:00
parent b6d42fb15d
commit 59496a8cd8
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
18 changed files with 470 additions and 14 deletions

View file

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

View file

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

@ -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

@ -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

@ -55,7 +55,7 @@
"account-already-linked": "This account is already linked with a pronouns.cc account.",
"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-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-2": "Allowed values are",
"validation-reason": "Reason",
@ -123,7 +123,7 @@
"fields-tab": "Fields",
"flags-links-tab": "Flags & links",
"back-to-settings-tab": "Back to settings",
"member-header": "Editing member {{name}}",
"member-header": "Editing profile of {{name}}",
"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-link": "Go to settings",
@ -131,8 +131,20 @@
"change-member-name": "Change name",
"display-name": "Display name",
"unlisted-label": "Hide from member list",
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:"
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
"edit-names-pronouns-header": "Edit names and pronouns",
"back-to-profile-tab": "Back to profile"
},
"save-changes": "Save changes",
"change": "Change"
"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

@ -5,6 +5,7 @@ import { createTippy } from "svelte-tippy";
// temporary (probably) until sveltestrap works with svelte 5
const tippy = createTippy({
animation: "scale-subtle",
delay: [null, 0],
});
export default tippy;

View file

@ -33,7 +33,7 @@
<h2>
{data.user.member_title || $t("profile.default-members-header")}
{#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 />
{$t("profile.create-member-button")}
</a>

View file

@ -8,14 +8,20 @@
let { data, children }: Props = $props();
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>
<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>
<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="col-md-3 mt-1 mb-3">
<div class="list-group">
@ -51,7 +57,7 @@
href="/@{data.user.username}/{data.member.name}"
class="list-group-item list-group-item-action text-danger"
>
Back to member
{$t("edit-profile.back-to-profile-tab")}
</a>
<a href="/settings/members" class="list-group-item list-group-item-action text-danger">
{$t("edit-profile.back-to-settings-tab")}

View file

@ -64,7 +64,8 @@ export const actions = {
},
bio: async ({ params, request, fetch, cookies }) => {
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 {
await fastRequest("PATCH", `/users/@me/members/${params.id}`, {

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 { page } from "$app/stores";
import { t } from "$lib/i18n";
import type { LayoutData } from "./$types";
type Props = { children: Snippet };
let { children }: Props = $props();
type Props = { data: LayoutData; children: Snippet };
let { data, children }: Props = $props();
const isActive = (path: string) => $page.url.pathname === path;
</script>
@ -53,6 +54,9 @@
>
{$t("edit-profile.flags-links-tab")}
</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">
{$t("edit-profile.back-to-settings-tab")}
</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>