Compare commits
4 commits
c0bb76580d
...
59496a8cd8
Author | SHA1 | Date | |
---|---|---|---|
59496a8cd8 | |||
b6d42fb15d | |||
004111feb6 | |||
c237aa8827 |
25 changed files with 707 additions and 159 deletions
|
@ -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}")]
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
|
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>
|
|
@ -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>
|
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
||||||
|
|
16
Foxnouns.Frontend/src/lib/defaultPronouns/en.ts
Normal file
16
Foxnouns.Frontend/src/lib/defaultPronouns/en.ts
Normal 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;
|
7
Foxnouns.Frontend/src/lib/defaultPronouns/index.ts
Normal file
7
Foxnouns.Frontend/src/lib/defaultPronouns/index.ts
Normal 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;
|
|
@ -55,7 +55,7 @@
|
||||||
"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",
|
||||||
|
@ -123,14 +123,28 @@
|
||||||
"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",
|
||||||
|
"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",
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
11
Foxnouns.Frontend/src/lib/tippy.ts
Normal file
11
Foxnouns.Frontend/src/lib/tippy.ts
Normal 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;
|
|
@ -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>
|
||||||
|
|
|
@ -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")}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
Loading…
Reference in a new issue