feat(frontend): edit names/pronouns
This commit is contained in:
parent
b6d42fb15d
commit
59496a8cd8
18 changed files with 470 additions and 14 deletions
|
@ -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";
|
||||
|
||||
|
|
20
Foxnouns.Frontend/src/lib/components/IconButton.svelte
Normal file
20
Foxnouns.Frontend/src/lib/components/IconButton.svelte
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
Loading…
Add table
Add a link
Reference in a new issue