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"
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										20
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										20
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							| 
						 | 
					@ -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;
 | 
				
			||||||
| 
						 | 
					@ -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>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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…
	
	Add table
		Add a link
		
	
		Reference in a new issue