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

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