forked from mirrors/pronouns.cc
merge: reworked edit pages
This commit is contained in:
commit
9ee6f318c7
44 changed files with 1741 additions and 1756 deletions
9
frontend/src/app.d.ts
vendored
9
frontend/src/app.d.ts
vendored
|
@ -1,8 +1,15 @@
|
||||||
// See https://kit.svelte.dev/docs/types#app
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
|
|
||||||
|
import type { ErrorCode } from "$lib/api/entities";
|
||||||
|
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
interface Error {
|
||||||
|
code: ErrorCode;
|
||||||
|
message?: string | undefined;
|
||||||
|
details?: string | undefined;
|
||||||
|
}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
|
|
10
frontend/src/lib/components/ActiveLink.svelte
Normal file
10
frontend/src/lib/components/ActiveLink.svelte
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { NavLink } from "sveltestrap";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
|
||||||
|
export let href: string;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavLink {href} active={$page.url.pathname === href}>
|
||||||
|
<slot />
|
||||||
|
</NavLink>
|
|
@ -1,17 +1,24 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
|
import { ErrorCode } from "$lib/api/entities";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Error - pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<h1>An error occurred ({$page.status})</h1>
|
<h1>An error occurred ({$page.status})</h1>
|
||||||
|
|
||||||
{#if $page.status === 404}
|
{#if $page.error?.code === ErrorCode.NotFound}
|
||||||
<p>
|
<p>
|
||||||
The page you were looking for was not found. If you're sure the page exists, check for any typos
|
The page you were looking for was not found. If you're sure the page exists, check for any typos
|
||||||
in the address.
|
in the address.
|
||||||
</p>
|
</p>
|
||||||
{:else if $page.status === 429}
|
{:else if $page.error?.code === ErrorCode.Forbidden || $page.error?.code === ErrorCode.InvalidToken}
|
||||||
|
<p>You're not logged in, or you aren't allowed to access this page.</p>
|
||||||
|
{:else if $page.error?.code === 429}
|
||||||
<p>You've exceeded a rate limit, please try again later.</p>
|
<p>You've exceeded a rate limit, please try again later.</p>
|
||||||
{:else if $page.status === 500}
|
{:else if $page.error?.code === 500}
|
||||||
<p>An internal error occurred. Please try again later.</p>
|
<p>An internal error occurred. Please try again later.</p>
|
||||||
<p>
|
<p>
|
||||||
If this error keeps happening, please <a
|
If this error keeps happening, please <a
|
||||||
|
@ -22,4 +29,4 @@
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<p>Error message: <code>{$page.error?.message}</code></p>
|
<p>Error code: <code>{$page.error?.code}</code></p>
|
||||||
|
|
|
@ -1,22 +1,39 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
|
import { ErrorCode } from "$lib/api/entities";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>An error occurred ({$page.status})</h1>
|
<svelte:head>
|
||||||
|
<title>Error - pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
{#if $page.status === 404}
|
{#if $page.error?.code === ErrorCode.Forbidden || $page.error?.code === ErrorCode.InvalidToken}
|
||||||
<p>The user you were looking for couldn't be found. Please check for any typos.</p>
|
<h1>Not logged in</h1>
|
||||||
{:else if $page.status === 429}
|
|
||||||
<p>You've exceeded a rate limit, please try again later.</p>
|
|
||||||
{:else if $page.status === 500}
|
|
||||||
<p>An internal error occurred. Please try again later.</p>
|
|
||||||
<p>
|
<p>
|
||||||
If this error keeps happening, please <a
|
Either you aren't logged in, or your login has expired. Please <a href="/auth/login"
|
||||||
href="https://codeberg.org/pronounscc/pronouns.cc/issues"
|
>log in again</a
|
||||||
target="_blank"
|
>.
|
||||||
rel="noreferrer">file a bug report</a
|
|
||||||
> with an explanation of what you did to cause the error.
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{:else if $page.error?.code === ErrorCode.NotOwnMember}
|
||||||
|
<h1>Not your member</h1>
|
||||||
|
<p>You can only edit your own members.</p>
|
||||||
|
{:else}
|
||||||
|
<h1>An error occurred ({$page.status})</h1>
|
||||||
|
|
||||||
<p>Error message: <code>{$page.error?.message}</code></p>
|
{#if $page.status === 404}
|
||||||
|
<p>The user you were looking for couldn't be found. Please check for any typos.</p>
|
||||||
|
{:else if $page.status === 429}
|
||||||
|
<p>You've exceeded a rate limit, please try again later.</p>
|
||||||
|
{:else if $page.status === 500}
|
||||||
|
<p>An internal error occurred. Please try again later.</p>
|
||||||
|
<p>
|
||||||
|
If this error keeps happening, please <a
|
||||||
|
href="https://codeberg.org/pronounscc/pronouns.cc/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer">file a bug report</a
|
||||||
|
> with an explanation of what you did to cause the error.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p>Error message: <code>{$page.error?.message}</code></p>
|
||||||
|
{/if}
|
||||||
|
|
|
@ -11,7 +11,7 @@ export const load = async ({ params }) => {
|
||||||
return resp;
|
return resp;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ((e as APIError).code === ErrorCode.UserNotFound) {
|
if ((e as APIError).code === ErrorCode.UserNotFound) {
|
||||||
throw error(404, (e as APIError).message);
|
throw error(404, e as APIError);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
|
|
|
@ -164,7 +164,7 @@
|
||||||
{#if $userStore && $userStore.id === data.id}
|
{#if $userStore && $userStore.id === data.id}
|
||||||
<Alert color="secondary" fade={false}>
|
<Alert color="secondary" fade={false}>
|
||||||
You are currently viewing your <strong>public</strong> profile.
|
You are currently viewing your <strong>public</strong> profile.
|
||||||
<br /><a href="/edit/profile">Edit your profile</a>
|
<br /><a href="/@{data.name}/edit">Edit your profile</a>
|
||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="grid row-gap-3">
|
<div class="grid row-gap-3">
|
||||||
|
@ -196,7 +196,7 @@
|
||||||
<hr />
|
<hr />
|
||||||
<p>
|
<p>
|
||||||
<em>
|
<em>
|
||||||
Your profile is empty! You can customize it by going to the <a href="/edit/profile"
|
Your profile is empty! You can customize it by going to the <a href="/@{data.name}/edit"
|
||||||
>edit profile</a
|
>edit profile</a
|
||||||
> page.</em
|
> page.</em
|
||||||
> <span class="text-muted">(only you can see this)</span>
|
> <span class="text-muted">(only you can see this)</span>
|
||||||
|
|
|
@ -14,9 +14,9 @@ export const load = async ({ params }) => {
|
||||||
(e as APIError).code === ErrorCode.UserNotFound ||
|
(e as APIError).code === ErrorCode.UserNotFound ||
|
||||||
(e as APIError).code === ErrorCode.MemberNotFound
|
(e as APIError).code === ErrorCode.MemberNotFound
|
||||||
) {
|
) {
|
||||||
throw error(404, (e as APIError).message);
|
throw error(404, e as APIError);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error(500, (e as APIError).message);
|
throw error(500, e as APIError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -65,7 +65,7 @@
|
||||||
<Alert color="secondary" fade={false}>
|
<Alert color="secondary" fade={false}>
|
||||||
You are currently viewing the <strong>public</strong> profile of {data.display_name ??
|
You are currently viewing the <strong>public</strong> profile of {data.display_name ??
|
||||||
data.name}.
|
data.name}.
|
||||||
<br /><a href="/edit/member/{data.id}">Edit profile</a>
|
<br /><a href="/@{data.user.name}/{data.name}/edit">Edit profile</a>
|
||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="m-3">
|
<div class="m-3">
|
||||||
|
@ -93,7 +93,7 @@
|
||||||
<p>
|
<p>
|
||||||
<em>
|
<em>
|
||||||
This member's profile is empty! You can customize it by going to the <a
|
This member's profile is empty! You can customize it by going to the <a
|
||||||
href="/edit/member/{data.id}">edit member</a
|
href="/@{data.user.name}/{data.name}/edit">edit member</a
|
||||||
> page.</em
|
> page.</em
|
||||||
> <span class="text-muted">(only you can see this)</span>
|
> <span class="text-muted">(only you can see this)</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
170
frontend/src/routes/@[username]/[memberName]/edit/+layout.svelte
Normal file
170
frontend/src/routes/@[username]/[memberName]/edit/+layout.svelte
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { APIError, Member, Pronoun } from "$lib/api/entities";
|
||||||
|
import { setContext } from "svelte";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
import type { LayoutData } from "./$types";
|
||||||
|
import { addToast, delToast } from "$lib/toast";
|
||||||
|
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
|
||||||
|
import { Button, ButtonGroup, Modal, ModalBody, ModalFooter, Nav, NavItem } from "sveltestrap";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
import ActiveLink from "$lib/components/ActiveLink.svelte";
|
||||||
|
import { memberNameRegex } from "$lib/api/regex";
|
||||||
|
|
||||||
|
export let data: LayoutData;
|
||||||
|
|
||||||
|
const member = writable<Member>(structuredClone({ ...data.member, avatar: null }));
|
||||||
|
const currentMember = writable(data.member);
|
||||||
|
setContext("member", member);
|
||||||
|
setContext("currentMember", currentMember);
|
||||||
|
|
||||||
|
// Whether the member's new name is valid.
|
||||||
|
// This is also checked in +page.svelte, because it's easier to do it twice.
|
||||||
|
let memberNameValid = true;
|
||||||
|
$: memberNameValid = memberNameRegex.test($member.name);
|
||||||
|
|
||||||
|
let error: APIError | null = null;
|
||||||
|
|
||||||
|
// Delete member code
|
||||||
|
let deleteOpen = false;
|
||||||
|
const toggleDeleteOpen = () => (deleteOpen = !deleteOpen);
|
||||||
|
let deleteName = "";
|
||||||
|
let deleteError: APIError | null = null;
|
||||||
|
|
||||||
|
const deleteMember = async () => {
|
||||||
|
try {
|
||||||
|
await fastFetchClient(`/members/${data.member.id}`, "DELETE");
|
||||||
|
|
||||||
|
toggleDeleteOpen();
|
||||||
|
addToast({
|
||||||
|
header: "Deleted member",
|
||||||
|
body: `Successfully deleted member ${data.member.name}!`,
|
||||||
|
});
|
||||||
|
goto(`/@${data.member.user.name}`);
|
||||||
|
} catch (e) {
|
||||||
|
deleteName = "";
|
||||||
|
deleteError = e as APIError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let deleteModalPronoun = "the member's";
|
||||||
|
$: deleteModalPronoun = updateModalPronoun($member.pronouns);
|
||||||
|
|
||||||
|
const updateModalPronoun = (pronouns: Pronoun[]) => {
|
||||||
|
const filtered = pronouns.filter((entry) => entry.status === "favourite");
|
||||||
|
if (filtered.length < 1) return "the member's";
|
||||||
|
|
||||||
|
const split = filtered[0].pronouns.split("/");
|
||||||
|
if (split.length !== 5) return "the member's";
|
||||||
|
return split[2];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMember = async () => {
|
||||||
|
const toastId = addToast({
|
||||||
|
header: "Saving changes",
|
||||||
|
body: "Saving changes, please wait...",
|
||||||
|
duration: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await apiFetchClient<Member>(`/members/${data.member.id}`, "PATCH", {
|
||||||
|
name: $member.name,
|
||||||
|
display_name: $member.display_name,
|
||||||
|
avatar: $member.avatar,
|
||||||
|
bio: $member.bio,
|
||||||
|
links: $member.links,
|
||||||
|
names: $member.names,
|
||||||
|
pronouns: $member.pronouns,
|
||||||
|
fields: $member.fields,
|
||||||
|
flags: $member.flags.map((flag) => flag.id),
|
||||||
|
unlisted: $member.unlisted,
|
||||||
|
});
|
||||||
|
|
||||||
|
addToast({ header: "Success", body: "Successfully saved changes!" });
|
||||||
|
|
||||||
|
data.member = resp;
|
||||||
|
$member.avatar = null;
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = e as APIError;
|
||||||
|
} finally {
|
||||||
|
delToast(toastId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Edit member profile - pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
Edit profile
|
||||||
|
<ButtonGroup>
|
||||||
|
<IconButton
|
||||||
|
color="secondary"
|
||||||
|
icon="chevron-left"
|
||||||
|
href="/@{$member.user.name}/{data.member.name}"
|
||||||
|
tooltip="Back to member"
|
||||||
|
/>
|
||||||
|
<Button color="success" on:click={() => updateMember()} disabled={!memberNameValid}>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
<Button color="danger" on:click={toggleDeleteOpen}
|
||||||
|
>Delete {data.member.display_name ?? data.member.name}</Button
|
||||||
|
>
|
||||||
|
</ButtonGroup>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<ErrorAlert {error} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Nav tabs>
|
||||||
|
<NavItem>
|
||||||
|
<ActiveLink href="/@{$member.user.name}/{$member.name}/edit">Names and avatar</ActiveLink>
|
||||||
|
</NavItem>
|
||||||
|
<NavItem>
|
||||||
|
<ActiveLink href="/@{$member.user.name}/{$member.name}/edit/bio">Bio</ActiveLink>
|
||||||
|
</NavItem>
|
||||||
|
<NavItem>
|
||||||
|
<ActiveLink href="/@{$member.user.name}/{$member.name}/edit/pronouns">Pronouns</ActiveLink>
|
||||||
|
</NavItem>
|
||||||
|
<NavItem>
|
||||||
|
<ActiveLink href="/@{$member.user.name}/{$member.name}/edit/fields">Fields</ActiveLink>
|
||||||
|
</NavItem>
|
||||||
|
<NavItem>
|
||||||
|
<ActiveLink href="/@{$member.user.name}/{$member.name}/edit/flags">Flags</ActiveLink>
|
||||||
|
</NavItem>
|
||||||
|
<NavItem>
|
||||||
|
<ActiveLink href="/@{$member.user.name}/{$member.name}/edit/links">Links</ActiveLink>
|
||||||
|
</NavItem>
|
||||||
|
<NavItem>
|
||||||
|
<ActiveLink href="/@{$member.user.name}/{$member.name}/edit/other">Other</ActiveLink>
|
||||||
|
</NavItem>
|
||||||
|
</Nav>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal header="Delete member" isOpen={deleteOpen} toggle={toggleDeleteOpen}>
|
||||||
|
<ModalBody>
|
||||||
|
<p>
|
||||||
|
If you want to delete this member, type {deleteModalPronoun} name (<code>{$member.name}</code
|
||||||
|
>) below:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<input type="text" class="form-control" bind:value={deleteName} />
|
||||||
|
</p>
|
||||||
|
{#if deleteError}
|
||||||
|
<ErrorAlert error={deleteError} />
|
||||||
|
{/if}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="danger" disabled={deleteName !== $member.name} on:click={deleteMember}>
|
||||||
|
Delete member
|
||||||
|
</Button>
|
||||||
|
<Button color="secondary" on:click={toggleDeleteOpen}>Cancel</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
48
frontend/src/routes/@[username]/[memberName]/edit/+layout.ts
Normal file
48
frontend/src/routes/@[username]/[memberName]/edit/+layout.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import {
|
||||||
|
type PrideFlag,
|
||||||
|
type MeUser,
|
||||||
|
type APIError,
|
||||||
|
type Member,
|
||||||
|
type PronounsJson,
|
||||||
|
ErrorCode,
|
||||||
|
} from "$lib/api/entities";
|
||||||
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
|
import { error, redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
import pronounsRaw from "$lib/pronouns.json";
|
||||||
|
import type { LayoutLoad } from "./$types";
|
||||||
|
const pronouns = pronounsRaw as PronounsJson;
|
||||||
|
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
|
export const load = (async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const user = await apiFetchClient<MeUser>(`/users/@me`);
|
||||||
|
const member = await apiFetchClient<Member>(
|
||||||
|
`/users/${params.username}/members/${params.memberName}`,
|
||||||
|
);
|
||||||
|
const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
|
||||||
|
|
||||||
|
if (user.id !== member.user.id) {
|
||||||
|
throw { code: ErrorCode.NotOwnMember, message: "Can only edit your own members" } as APIError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.name !== params.username ||
|
||||||
|
member.user.name !== params.username ||
|
||||||
|
member.name !== params.memberName
|
||||||
|
) {
|
||||||
|
throw redirect(303, `/@${user.name}/${member.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
member,
|
||||||
|
pronouns: pronouns.autocomplete,
|
||||||
|
flags,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if ("code" in e) throw error(500, e as APIError);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}) satisfies LayoutLoad;
|
151
frontend/src/routes/@[username]/[memberName]/edit/+page.svelte
Normal file
151
frontend/src/routes/@[username]/[memberName]/edit/+page.svelte
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { encode } from "base64-arraybuffer";
|
||||||
|
import { FormGroup, Icon, Input } from "sveltestrap";
|
||||||
|
import { memberAvatars, type Member } from "$lib/api/entities";
|
||||||
|
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||||
|
import EditableName from "$lib/components/edit/EditableName.svelte";
|
||||||
|
import { addToast } from "$lib/toast";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
import { memberNameRegex } from "$lib/api/regex";
|
||||||
|
|
||||||
|
const MAX_AVATAR_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
const member = getContext<Writable<Member>>("member");
|
||||||
|
const currentMember = getContext<Writable<Member>>("currentMember");
|
||||||
|
|
||||||
|
// Whether the member's new name is valid.
|
||||||
|
// This is also checked in +layout.svelte, because it's easier to do it twice.
|
||||||
|
let memberNameValid = true;
|
||||||
|
$: memberNameValid = memberNameRegex.test($member.name);
|
||||||
|
|
||||||
|
// The list of avatar files uploaded.
|
||||||
|
// Only the first of these is ever used.
|
||||||
|
let avatar_files: FileList | null;
|
||||||
|
$: getAvatar(avatar_files).then((b64) => ($member.avatar = b64));
|
||||||
|
|
||||||
|
// The variable for a new name being inputted.
|
||||||
|
let newName = "";
|
||||||
|
|
||||||
|
const moveName = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == $member.names.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const temp = $member.names[index];
|
||||||
|
$member.names[index] = $member.names[newIndex];
|
||||||
|
$member.names[newIndex] = temp;
|
||||||
|
$member.names = [...$member.names];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeName = (index: number) => {
|
||||||
|
$member.names.splice(index, 1);
|
||||||
|
$member.names = [...$member.names];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addName = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
$member.names = [...$member.names, { value: newName, status: "okay" }];
|
||||||
|
newName = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvatar = async (list: FileList | null) => {
|
||||||
|
if (!list || list.length === 0) return null;
|
||||||
|
if (list[0].size > MAX_AVATAR_BYTES) {
|
||||||
|
addToast({
|
||||||
|
header: "Avatar too large",
|
||||||
|
body: `This avatar is too large, please resize it (maximum is ${prettyBytes(
|
||||||
|
MAX_AVATAR_BYTES,
|
||||||
|
)}, the file you tried to upload is ${prettyBytes(list[0].size)})`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await list[0].arrayBuffer();
|
||||||
|
const base64 = encode(buffer);
|
||||||
|
|
||||||
|
const uri = `data:${list[0].type};base64,${base64}`;
|
||||||
|
console.log(uri.slice(0, 128));
|
||||||
|
|
||||||
|
return uri;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md text-center">
|
||||||
|
{#if $member.avatar === ""}
|
||||||
|
<FallbackImage alt="Current avatar" urls={[]} width={200} />
|
||||||
|
{:else if $member.avatar}
|
||||||
|
<img
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
src={$member.avatar}
|
||||||
|
alt="New avatar"
|
||||||
|
class="rounded-circle img-fluid"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<FallbackImage alt="Current avatar" urls={memberAvatars($currentMember)} width={200} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="avatar"
|
||||||
|
type="file"
|
||||||
|
bind:files={avatar_files}
|
||||||
|
accept="image/png, image/jpeg, image/gif, image/webp"
|
||||||
|
/>
|
||||||
|
<p class="text-muted mt-3">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden /> Only PNG, JPEG, GIF, and WebP images can be used
|
||||||
|
as avatars. Avatars cannot be larger than 1 MB, and animated avatars will be made static.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<!-- svelte-ignore a11y-invalid-attribute -->
|
||||||
|
<a href="" on:click={() => ($member.avatar = "")}>Remove avatar</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<FormGroup floating label="Name">
|
||||||
|
<Input bind:value={$member.name} />
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
The member name is only used as part of the link to their profile page.
|
||||||
|
</p>
|
||||||
|
</FormGroup>
|
||||||
|
{#if !memberNameValid}
|
||||||
|
<p class="text-danger-emphasis mb-2">That member name is not valid.</p>
|
||||||
|
{/if}
|
||||||
|
<FormGroup floating label="Display name">
|
||||||
|
<Input bind:value={$member.display_name} />
|
||||||
|
</FormGroup>
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
Your display name is used in page titles and as a header.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Names</h4>
|
||||||
|
{#each $member.names as _, index}
|
||||||
|
<EditableName
|
||||||
|
bind:value={$member.names[index].value}
|
||||||
|
bind:status={$member.names[index].status}
|
||||||
|
preferences={$member.user.custom_preferences}
|
||||||
|
moveUp={() => moveName(index, true)}
|
||||||
|
moveDown={() => moveName(index, false)}
|
||||||
|
remove={() => removeName(index)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<form class="input-group m-1" on:submit={addName}>
|
||||||
|
<input type="text" class="form-control" bind:value={newName} />
|
||||||
|
<IconButton type="submit" color="success" icon="plus" tooltip="Add name" />
|
||||||
|
</form>
|
||||||
|
</div>
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { MAX_DESCRIPTION_LENGTH, type Member } from "$lib/api/entities";
|
||||||
|
import { charCount, renderMarkdown } from "$lib/utils";
|
||||||
|
import MarkdownHelp from "$lib/components/edit/MarkdownHelp.svelte";
|
||||||
|
import { Card, CardBody, CardHeader } from "sveltestrap";
|
||||||
|
|
||||||
|
const member = getContext<Writable<Member>>("member");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form">
|
||||||
|
<textarea class="form-control" style="height: 200px;" bind:value={$member.bio} />
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
Using {charCount($member.bio || "")}/{MAX_DESCRIPTION_LENGTH} characters
|
||||||
|
</p>
|
||||||
|
<p class="text-muted my-2">
|
||||||
|
<MarkdownHelp />
|
||||||
|
</p>
|
||||||
|
{#if $member.bio}
|
||||||
|
<hr />
|
||||||
|
<Card>
|
||||||
|
<CardHeader>Preview</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{@html renderMarkdown($member.bio)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
|
@ -0,0 +1,53 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { Alert, Button, Icon } from "sveltestrap";
|
||||||
|
|
||||||
|
import type { Member } from "$lib/api/entities";
|
||||||
|
import EditableField from "$lib/components/edit/EditableField.svelte";
|
||||||
|
|
||||||
|
const member = getContext<Writable<Member>>("member");
|
||||||
|
|
||||||
|
const moveField = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == $member.fields.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const temp = $member.fields[index];
|
||||||
|
$member.fields[index] = $member.fields[newIndex];
|
||||||
|
$member.fields[newIndex] = temp;
|
||||||
|
$member.fields = [...$member.fields];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeField = (index: number) => {
|
||||||
|
$member.fields.splice(index, 1);
|
||||||
|
$member.fields = [...$member.fields];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $member.fields.length === 0}
|
||||||
|
<Alert class="mt-3" color="secondary" fade={false}>
|
||||||
|
Fields are extra categories you can add separate from names and pronouns.<br />
|
||||||
|
For example, you could use them for gender terms, honorifics, or compliments.
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div class="row row-cols-1 row-cols-md-2">
|
||||||
|
{#each $member.fields as _, index}
|
||||||
|
<EditableField
|
||||||
|
bind:field={$member.fields[index]}
|
||||||
|
preferences={$member.user.custom_preferences}
|
||||||
|
deleteField={() => removeField(index)}
|
||||||
|
moveField={(up) => moveField(index, up)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
on:click={() => ($member.fields = [...$member.fields, { name: "New field", entries: [] }])}
|
||||||
|
>
|
||||||
|
<Icon name="plus" aria-hidden /> Add new field
|
||||||
|
</Button>
|
||||||
|
</div>
|
|
@ -0,0 +1,104 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { Alert, ButtonGroup, Input } from "sveltestrap";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
import type { Member, PrideFlag } from "$lib/api/entities";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
import FlagButton from "$lib/components/edit/FlagButton.svelte";
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
const member = getContext<Writable<Member>>("member");
|
||||||
|
|
||||||
|
let flagSearch = "";
|
||||||
|
let filteredFlags: PrideFlag[];
|
||||||
|
$: filteredFlags = filterFlags(flagSearch, data.flags);
|
||||||
|
|
||||||
|
const filterFlags = (search: string, flags: PrideFlag[]) => {
|
||||||
|
return (
|
||||||
|
search
|
||||||
|
? flags.filter((flag) => flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||||
|
: flags
|
||||||
|
).slice(0, 25);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFlag = (flag: PrideFlag) => {
|
||||||
|
$member.flags = [...$member.flags, flag];
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveFlag = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == $member.flags.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const temp = $member.flags[index];
|
||||||
|
$member.flags[index] = $member.flags[newIndex];
|
||||||
|
$member.flags[newIndex] = temp;
|
||||||
|
$member.flags = [...$member.flags];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFlag = (index: number) => {
|
||||||
|
$member.flags.splice(index, 1);
|
||||||
|
$member.flags = [...$member.flags];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#each $member.flags as _, index}
|
||||||
|
<ButtonGroup class="m-1">
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-left"
|
||||||
|
color="secondary"
|
||||||
|
tooltip="Move flag to the left"
|
||||||
|
click={() => moveFlag(index, true)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-right"
|
||||||
|
color="secondary"
|
||||||
|
tooltip="Move flag to the right"
|
||||||
|
click={() => moveFlag(index, false)}
|
||||||
|
/>
|
||||||
|
<FlagButton
|
||||||
|
flag={$member.flags[index]}
|
||||||
|
tooltip="Remove this flag from your profile"
|
||||||
|
on:click={() => removeFlag(index)}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<Input placeholder="Filter flags" bind:value={flagSearch} disabled={data.flags.length === 0} />
|
||||||
|
<div class="p-2">
|
||||||
|
{#each filteredFlags as flag (flag.id)}
|
||||||
|
<FlagButton {flag} tooltip="Add this flag to your profile" on:click={() => addFlag(flag)} />
|
||||||
|
{:else}
|
||||||
|
{#if data.flags.length === 0}
|
||||||
|
You haven't uploaded any flags yet.
|
||||||
|
{:else}
|
||||||
|
There are no flags matching your search <strong>{flagSearch}</strong>.
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<Alert color="secondary" fade={false}>
|
||||||
|
{#if data.flags.length === 0}
|
||||||
|
<p><strong>Why can't I see any flags?</strong></p>
|
||||||
|
<p>
|
||||||
|
There are thousands of pride flags, and it would be impossible to bundle all of them by
|
||||||
|
default. Many labels also have multiple different flags that are favoured by different
|
||||||
|
people. Because of this, there are no flags available by default--instead, you can upload
|
||||||
|
flags in your <a href="/settings/flags">settings</a>. Your main profile and your member
|
||||||
|
profiles can all have different flags.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
To upload and delete flags, go to your <a href="/settings/flags">settings</a>.
|
||||||
|
{/if}
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,63 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
|
||||||
|
import type { Member } from "$lib/api/entities";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
|
||||||
|
const member = getContext<Writable<Member>>("member");
|
||||||
|
|
||||||
|
let newLink = "";
|
||||||
|
|
||||||
|
const addLink = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
$member.links = [...$member.links, newLink];
|
||||||
|
newLink = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLink = (index: number) => {
|
||||||
|
$member.links.splice(index, 1);
|
||||||
|
$member.links = [...$member.links];
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveLink = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == $member.links.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const temp = $member.links[index];
|
||||||
|
$member.links[index] = $member.links[newIndex];
|
||||||
|
$member.links[newIndex] = temp;
|
||||||
|
$member.links = [...$member.links];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each $member.links as _, index}
|
||||||
|
<div class="input-group m-1">
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-up"
|
||||||
|
color="secondary"
|
||||||
|
tooltip="Move link up"
|
||||||
|
click={() => moveLink(index, true)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-down"
|
||||||
|
color="secondary"
|
||||||
|
tooltip="Move link down"
|
||||||
|
click={() => moveLink(index, false)}
|
||||||
|
/>
|
||||||
|
<input type="text" class="form-control" bind:value={$member.links[index]} />
|
||||||
|
<IconButton
|
||||||
|
color="danger"
|
||||||
|
icon="trash3"
|
||||||
|
tooltip="Remove link"
|
||||||
|
click={() => removeLink(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<form class="input-group m-1" on:submit={addLink}>
|
||||||
|
<input type="text" class="form-control" bind:value={newLink} />
|
||||||
|
<IconButton type="submit" color="success" icon="plus" tooltip="Add link" />
|
||||||
|
</form>
|
|
@ -0,0 +1,99 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { Button, ButtonGroup, Icon } from "sveltestrap";
|
||||||
|
|
||||||
|
import type { APIError, Member } from "$lib/api/entities";
|
||||||
|
import { PUBLIC_SHORT_BASE } from "$env/static/public";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
|
import { addToast } from "$lib/toast";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
const member = getContext<Writable<Member>>("member");
|
||||||
|
|
||||||
|
let error: APIError | null = null;
|
||||||
|
|
||||||
|
const now = DateTime.now().toLocal();
|
||||||
|
let canRerollSid: boolean;
|
||||||
|
$: canRerollSid =
|
||||||
|
now.diff(DateTime.fromISO(data.user.last_sid_reroll).toLocal(), "hours").hours >= 1;
|
||||||
|
|
||||||
|
const rerollSid = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiFetchClient<Member>(`/members/${data.member.id}/reroll`);
|
||||||
|
addToast({ header: "Success", body: "Rerolled short ID!" });
|
||||||
|
error = null;
|
||||||
|
$member.sid = resp.sid;
|
||||||
|
} catch (e) {
|
||||||
|
error = e as APIError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyShortURL = async () => {
|
||||||
|
const url = `${PUBLIC_SHORT_BASE}/${data.member.sid}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
addToast({ body: "Copied the short link to your clipboard!", duration: 2000 });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<ErrorAlert {error} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={$member.unlisted}
|
||||||
|
id="unlisted"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="unlisted">Hide from member list</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
{#if data.user.list_private}
|
||||||
|
<Icon name="exclamation-triangle-fill" aria-hidden />
|
||||||
|
Your member list is currently hidden, so <strong>this setting has no effect</strong>. If you
|
||||||
|
want to make your member list visible again,
|
||||||
|
<a href="/@{$member.user.name}/other">edit your user profile</a>.
|
||||||
|
<br />
|
||||||
|
{/if}
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
This <em>only</em> hides this member from your member list.
|
||||||
|
<strong>
|
||||||
|
This member will still be visible to anyone at
|
||||||
|
<code class="text-nowrap">pronouns.cc/@{$member.user.name}/{$member.name}</code>.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if PUBLIC_SHORT_BASE}
|
||||||
|
<div class="col-md">
|
||||||
|
<p>
|
||||||
|
Current short ID: <code>{$member.sid}</code>
|
||||||
|
<ButtonGroup class="mb-1">
|
||||||
|
<Button color="secondary" disabled={!canRerollSid} on:click={() => rerollSid()}
|
||||||
|
>Reroll short ID</Button
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="link-45deg"
|
||||||
|
tooltip="Copy short link"
|
||||||
|
color="secondary"
|
||||||
|
click={copyShortURL}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
<br />
|
||||||
|
<span class="text-muted">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
This ID is used in <code>prns.cc</code> links. You can reroll one short ID every hour (shared
|
||||||
|
between your main profile and all members) by pressing the button above.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
|
@ -0,0 +1,84 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import type { Member } from "$lib/api/entities";
|
||||||
|
import { Button, Icon, Popover } from "sveltestrap";
|
||||||
|
import EditablePronouns from "$lib/components/edit/EditablePronouns.svelte";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
const member = getContext<Writable<Member>>("member");
|
||||||
|
let newPronouns = "";
|
||||||
|
|
||||||
|
const movePronoun = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == $member.pronouns.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const temp = $member.pronouns[index];
|
||||||
|
$member.pronouns[index] = $member.pronouns[newIndex];
|
||||||
|
$member.pronouns[newIndex] = temp;
|
||||||
|
$member.pronouns = [...$member.pronouns];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPronouns = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (newPronouns in data.pronouns) {
|
||||||
|
const fullSet = data.pronouns[newPronouns];
|
||||||
|
$member.pronouns = [
|
||||||
|
...$member.pronouns,
|
||||||
|
{
|
||||||
|
pronouns: fullSet.pronouns.join("/"),
|
||||||
|
display_text: fullSet.display || null,
|
||||||
|
status: "okay",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$member.pronouns = [
|
||||||
|
...$member.pronouns,
|
||||||
|
{ pronouns: newPronouns, display_text: null, status: "okay" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
newPronouns = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePronoun = (index: number) => {
|
||||||
|
$member.pronouns.splice(index, 1);
|
||||||
|
$member.pronouns = [...$member.pronouns];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each $member.pronouns as _, index}
|
||||||
|
<EditablePronouns
|
||||||
|
bind:pronoun={$member.pronouns[index]}
|
||||||
|
preferences={$member.user.custom_preferences}
|
||||||
|
moveUp={() => movePronoun(index, true)}
|
||||||
|
moveDown={() => movePronoun(index, false)}
|
||||||
|
remove={() => removePronoun(index)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<form class="input-group m-1" on:submit={addPronouns}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="New pronouns"
|
||||||
|
bind:value={newPronouns}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
type="submit"
|
||||||
|
color="success"
|
||||||
|
icon="plus"
|
||||||
|
tooltip="Add pronouns"
|
||||||
|
disabled={newPronouns === ""}
|
||||||
|
/>
|
||||||
|
<Button id="pronouns-help" color="secondary"><Icon name="question" /></Button>
|
||||||
|
<Popover target="pronouns-help" placement="bottom">
|
||||||
|
For common pronouns, the short form (e.g. "she/her" or "he/him") is enough; for less common
|
||||||
|
pronouns, you will have to use all five forms (e.g. "ce/cir/cir/cirs/cirself").
|
||||||
|
</Popover>
|
||||||
|
</form>
|
94
frontend/src/routes/@[username]/edit/+layout.svelte
Normal file
94
frontend/src/routes/@[username]/edit/+layout.svelte
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { setContext } from "svelte";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
import type { LayoutData } from "./$types";
|
||||||
|
import { Button, ButtonGroup, Icon, Nav, NavItem } from "sveltestrap";
|
||||||
|
import type { MeUser, APIError } from "$lib/api/entities";
|
||||||
|
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||||
|
import { addToast, delToast } from "$lib/toast";
|
||||||
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
|
import { userStore } from "$lib/store";
|
||||||
|
import ActiveLink from "$lib/components/ActiveLink.svelte";
|
||||||
|
|
||||||
|
export let data: LayoutData;
|
||||||
|
|
||||||
|
// The user context used in all other pages.
|
||||||
|
// Avatar is explicitly set to null here as it holds the base64-encoded version of the new avatar later.
|
||||||
|
const user = writable<MeUser>(structuredClone({ ...data.user, avatar: null }));
|
||||||
|
const currentUser = writable<MeUser>(data.user);
|
||||||
|
setContext("user", user);
|
||||||
|
setContext("currentUser", currentUser);
|
||||||
|
|
||||||
|
let error: APIError | null = null;
|
||||||
|
|
||||||
|
const updateUser = async () => {
|
||||||
|
const toastId = addToast({
|
||||||
|
header: "Saving changes",
|
||||||
|
body: "Saving changes, please wait...",
|
||||||
|
duration: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await apiFetchClient<MeUser>("/users/@me", "PATCH", {
|
||||||
|
display_name: $user.display_name || "",
|
||||||
|
avatar: $user.avatar,
|
||||||
|
bio: $user.bio,
|
||||||
|
links: $user.links,
|
||||||
|
names: $user.names,
|
||||||
|
pronouns: $user.pronouns,
|
||||||
|
fields: $user.fields,
|
||||||
|
member_title: $user.member_title || "",
|
||||||
|
list_private: $user.list_private,
|
||||||
|
timezone: $user.timezone || "",
|
||||||
|
custom_preferences: $user.custom_preferences,
|
||||||
|
flags: $user.flags.map((flag) => flag.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
currentUser.set(resp);
|
||||||
|
userStore.set(resp);
|
||||||
|
localStorage.setItem("pronouns-user", JSON.stringify(resp));
|
||||||
|
|
||||||
|
addToast({ header: "Success", body: "Successfully saved changes!" });
|
||||||
|
|
||||||
|
user.update((_) => ({ ...resp, avatar: null }));
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = e as APIError;
|
||||||
|
} finally {
|
||||||
|
delToast(toastId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Edit profile - pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
Edit profile
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button color="secondary" href="/@{data.user.name}">
|
||||||
|
<Icon name="chevron-left" />
|
||||||
|
Back to your profile
|
||||||
|
</Button>
|
||||||
|
<Button color="success" on:click={() => updateUser()}>Save changes</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<ErrorAlert {error} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Nav tabs>
|
||||||
|
<NavItem><ActiveLink href="/@{$user.name}/edit">Names and avatar</ActiveLink></NavItem>
|
||||||
|
<NavItem><ActiveLink href="/@{$user.name}/edit/bio">Bio</ActiveLink></NavItem>
|
||||||
|
<NavItem><ActiveLink href="/@{$user.name}/edit/pronouns">Pronouns</ActiveLink></NavItem>
|
||||||
|
<NavItem><ActiveLink href="/@{$user.name}/edit/fields">Fields</ActiveLink></NavItem>
|
||||||
|
<NavItem><ActiveLink href="/@{$user.name}/edit/flags">Flags</ActiveLink></NavItem>
|
||||||
|
<NavItem><ActiveLink href="/@{$user.name}/edit/links">Links</ActiveLink></NavItem>
|
||||||
|
<NavItem><ActiveLink href="/@{$user.name}/edit/other">Preferences & other</ActiveLink></NavItem>
|
||||||
|
</Nav>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<slot />
|
||||||
|
</div>
|
28
frontend/src/routes/@[username]/edit/+layout.ts
Normal file
28
frontend/src/routes/@[username]/edit/+layout.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import type { PrideFlag, APIError, MeUser, PronounsJson } from "$lib/api/entities";
|
||||||
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
|
import { error, redirect, type Redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
import pronounsRaw from "$lib/pronouns.json";
|
||||||
|
const pronouns = pronounsRaw as PronounsJson;
|
||||||
|
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
|
export const load = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const user = await apiFetchClient<MeUser>(`/users/@me`);
|
||||||
|
const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
|
||||||
|
|
||||||
|
if (params.username !== user.name) {
|
||||||
|
throw redirect(303, `/@${user.name}/edit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
pronouns: pronouns.autocomplete,
|
||||||
|
flags,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if ("code" in e) throw error(500, e as APIError);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
143
frontend/src/routes/@[username]/edit/+page.svelte
Normal file
143
frontend/src/routes/@[username]/edit/+page.svelte
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { encode } from "base64-arraybuffer";
|
||||||
|
import { FormGroup, Icon, Input } from "sveltestrap";
|
||||||
|
import { userAvatars, type MeUser } from "$lib/api/entities";
|
||||||
|
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||||
|
import EditableName from "$lib/components/edit/EditableName.svelte";
|
||||||
|
import { addToast } from "$lib/toast";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
|
||||||
|
const MAX_AVATAR_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
const user = getContext<Writable<MeUser>>("user");
|
||||||
|
const currentUser = getContext<Writable<MeUser>>("currentUser");
|
||||||
|
|
||||||
|
// The list of avatar files uploaded by the user.
|
||||||
|
// Only the first of these is ever used.
|
||||||
|
let avatar_files: FileList | null;
|
||||||
|
$: getAvatar(avatar_files).then((b64) => ($user.avatar = b64));
|
||||||
|
|
||||||
|
// The variable for a new name being inputted by the user.
|
||||||
|
let newName = "";
|
||||||
|
|
||||||
|
const moveName = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == $user.names.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const temp = $user.names[index];
|
||||||
|
$user.names[index] = $user.names[newIndex];
|
||||||
|
$user.names[newIndex] = temp;
|
||||||
|
$user.names = [...$user.names];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeName = (index: number) => {
|
||||||
|
$user.names.splice(index, 1);
|
||||||
|
$user.names = [...$user.names];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addName = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
$user.names = [...$user.names, { value: newName, status: "okay" }];
|
||||||
|
newName = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvatar = async (list: FileList | null) => {
|
||||||
|
if (!list || list.length === 0) return null;
|
||||||
|
if (list[0].size > MAX_AVATAR_BYTES) {
|
||||||
|
addToast({
|
||||||
|
header: "Avatar too large",
|
||||||
|
body: `This avatar is too large, please resize it (maximum is ${prettyBytes(
|
||||||
|
MAX_AVATAR_BYTES,
|
||||||
|
)}, the file you tried to upload is ${prettyBytes(list[0].size)})`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await list[0].arrayBuffer();
|
||||||
|
const base64 = encode(buffer);
|
||||||
|
|
||||||
|
const uri = `data:${list[0].type};base64,${base64}`;
|
||||||
|
console.log(uri.slice(0, 128));
|
||||||
|
|
||||||
|
return uri;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md text-center">
|
||||||
|
{#if $user.avatar === ""}
|
||||||
|
<FallbackImage alt="Current avatar" urls={[]} width={200} />
|
||||||
|
{:else if $user.avatar}
|
||||||
|
<img
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
src={$user.avatar}
|
||||||
|
alt="New avatar"
|
||||||
|
class="rounded-circle img-fluid"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<FallbackImage alt="Current avatar" urls={userAvatars($currentUser)} width={200} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="avatar"
|
||||||
|
type="file"
|
||||||
|
bind:files={avatar_files}
|
||||||
|
accept="image/png, image/jpeg, image/gif, image/webp"
|
||||||
|
/>
|
||||||
|
<p class="text-muted mt-3">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden /> Only PNG, JPEG, GIF, and WebP images can be used
|
||||||
|
as avatars. Avatars cannot be larger than 1 MB, and animated avatars will be made static.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<!-- svelte-ignore a11y-invalid-attribute -->
|
||||||
|
<a href="" on:click={() => ($user.avatar = "")}>Remove avatar</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<FormGroup floating label="Username">
|
||||||
|
<Input bind:value={$user.name} readonly />
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
You can change your username in
|
||||||
|
<a href="/settings" class="text-reset">your settings</a>.
|
||||||
|
</p>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup floating label="Display name">
|
||||||
|
<Input bind:value={$user.display_name} />
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
Your display name is used in page titles and as a header.
|
||||||
|
</p>
|
||||||
|
</FormGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Names</h4>
|
||||||
|
{#each $user.names as _, index}
|
||||||
|
<EditableName
|
||||||
|
bind:value={$user.names[index].value}
|
||||||
|
bind:status={$user.names[index].status}
|
||||||
|
preferences={$user.custom_preferences}
|
||||||
|
moveUp={() => moveName(index, true)}
|
||||||
|
moveDown={() => moveName(index, false)}
|
||||||
|
remove={() => removeName(index)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<form class="input-group m-1" on:submit={addName}>
|
||||||
|
<input type="text" class="form-control" bind:value={newName} />
|
||||||
|
<IconButton type="submit" color="success" icon="plus" tooltip="Add name" />
|
||||||
|
</form>
|
||||||
|
</div>
|
29
frontend/src/routes/@[username]/edit/bio/+page.svelte
Normal file
29
frontend/src/routes/@[username]/edit/bio/+page.svelte
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { MAX_DESCRIPTION_LENGTH, type MeUser } from "$lib/api/entities";
|
||||||
|
import { charCount, renderMarkdown } from "$lib/utils";
|
||||||
|
import MarkdownHelp from "$lib/components/edit/MarkdownHelp.svelte";
|
||||||
|
import { Card, CardBody, CardHeader } from "sveltestrap";
|
||||||
|
|
||||||
|
const user = getContext<Writable<MeUser>>("user");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form">
|
||||||
|
<textarea class="form-control" style="height: 200px;" bind:value={$user.bio} />
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
Using {charCount($user.bio || "")}/{MAX_DESCRIPTION_LENGTH} characters
|
||||||
|
</p>
|
||||||
|
<p class="text-muted my-2">
|
||||||
|
<MarkdownHelp />
|
||||||
|
</p>
|
||||||
|
{#if $user.bio}
|
||||||
|
<hr />
|
||||||
|
<Card>
|
||||||
|
<CardHeader>Preview</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{@html renderMarkdown($user.bio)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
51
frontend/src/routes/@[username]/edit/fields/+page.svelte
Normal file
51
frontend/src/routes/@[username]/edit/fields/+page.svelte
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { Alert, Button, Icon } from "sveltestrap";
|
||||||
|
|
||||||
|
import type { MeUser } from "$lib/api/entities";
|
||||||
|
import EditableField from "$lib/components/edit/EditableField.svelte";
|
||||||
|
|
||||||
|
const user = getContext<Writable<MeUser>>("user");
|
||||||
|
|
||||||
|
const moveField = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == $user.fields.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const temp = $user.fields[index];
|
||||||
|
$user.fields[index] = $user.fields[newIndex];
|
||||||
|
$user.fields[newIndex] = temp;
|
||||||
|
$user.fields = [...$user.fields];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeField = (index: number) => {
|
||||||
|
$user.fields.splice(index, 1);
|
||||||
|
$user.fields = [...$user.fields];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $user.fields.length === 0}
|
||||||
|
<Alert class="mt-3" color="secondary" fade={false}>
|
||||||
|
Fields are extra categories you can add separate from names and pronouns.<br />
|
||||||
|
For example, you could use them for gender terms, honorifics, or compliments.
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div class="row row-cols-1 row-cols-md-2">
|
||||||
|
{#each $user.fields as _, index}
|
||||||
|
<EditableField
|
||||||
|
bind:field={$user.fields[index]}
|
||||||
|
preferences={$user.custom_preferences}
|
||||||
|
deleteField={() => removeField(index)}
|
||||||
|
moveField={(up) => moveField(index, up)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button on:click={() => ($user.fields = [...$user.fields, { name: "New field", entries: [] }])}>
|
||||||
|
<Icon name="plus" aria-hidden /> Add new field
|
||||||
|
</Button>
|
||||||
|
</div>
|
104
frontend/src/routes/@[username]/edit/flags/+page.svelte
Normal file
104
frontend/src/routes/@[username]/edit/flags/+page.svelte
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { Alert, ButtonGroup, Input } from "sveltestrap";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
import type { MeUser, PrideFlag } from "$lib/api/entities";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
import FlagButton from "$lib/components/edit/FlagButton.svelte";
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
const user = getContext<Writable<MeUser>>("user");
|
||||||
|
|
||||||
|
let flagSearch = "";
|
||||||
|
let filteredFlags: PrideFlag[];
|
||||||
|
$: filteredFlags = filterFlags(flagSearch, data.flags);
|
||||||
|
|
||||||
|
const filterFlags = (search: string, flags: PrideFlag[]) => {
|
||||||
|
return (
|
||||||
|
search
|
||||||
|
? flags.filter((flag) => flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||||
|
: flags
|
||||||
|
).slice(0, 25);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFlag = (flag: PrideFlag) => {
|
||||||
|
$user.flags = [...$user.flags, flag];
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveFlag = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == $user.flags.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const temp = $user.flags[index];
|
||||||
|
$user.flags[index] = $user.flags[newIndex];
|
||||||
|
$user.flags[newIndex] = temp;
|
||||||
|
$user.flags = [...$user.flags];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFlag = (index: number) => {
|
||||||
|
$user.flags.splice(index, 1);
|
||||||
|
$user.flags = [...$user.flags];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#each $user.flags as _, index}
|
||||||
|
<ButtonGroup class="m-1">
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-left"
|
||||||
|
color="secondary"
|
||||||
|
tooltip="Move flag to the left"
|
||||||
|
click={() => moveFlag(index, true)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-right"
|
||||||
|
color="secondary"
|
||||||
|
tooltip="Move flag to the right"
|
||||||
|
click={() => moveFlag(index, false)}
|
||||||
|
/>
|
||||||
|
<FlagButton
|
||||||
|
flag={$user.flags[index]}
|
||||||
|
tooltip="Remove this flag from your profile"
|
||||||
|
on:click={() => removeFlag(index)}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<Input placeholder="Filter flags" bind:value={flagSearch} disabled={data.flags.length === 0} />
|
||||||
|
<div class="p-2">
|
||||||
|
{#each filteredFlags as flag (flag.id)}
|
||||||
|
<FlagButton {flag} tooltip="Add this flag to your profile" on:click={() => addFlag(flag)} />
|
||||||
|
{:else}
|
||||||
|
{#if data.flags.length === 0}
|
||||||
|
You haven't uploaded any flags yet.
|
||||||
|
{:else}
|
||||||
|
There are no flags matching your search <strong>{flagSearch}</strong>.
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<Alert color="secondary" fade={false}>
|
||||||
|
{#if data.flags.length === 0}
|
||||||
|
<p><strong>Why can't I see any flags?</strong></p>
|
||||||
|
<p>
|
||||||
|
There are thousands of pride flags, and it would be impossible to bundle all of them by
|
||||||
|
default. Many labels also have multiple different flags that are favoured by different
|
||||||
|
people. Because of this, there are no flags available by default--instead, you can upload
|
||||||
|
flags in your <a href="/settings/flags">settings</a>. Your main profile and your member
|
||||||
|
profiles can all have different flags.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
To upload and delete flags, go to your <a href="/settings/flags">settings</a>.
|
||||||
|
{/if}
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
63
frontend/src/routes/@[username]/edit/links/+page.svelte
Normal file
63
frontend/src/routes/@[username]/edit/links/+page.svelte
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
|
||||||
|
import type { MeUser } from "$lib/api/entities";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
|
||||||
|
const user = getContext<Writable<MeUser>>("user");
|
||||||
|
|
||||||
|
let newLink = "";
|
||||||
|
|
||||||
|
const addLink = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
$user.links = [...$user.links, newLink];
|
||||||
|
newLink = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLink = (index: number) => {
|
||||||
|
$user.links.splice(index, 1);
|
||||||
|
$user.links = [...$user.links];
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveLink = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == $user.links.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const temp = $user.links[index];
|
||||||
|
$user.links[index] = $user.links[newIndex];
|
||||||
|
$user.links[newIndex] = temp;
|
||||||
|
$user.links = [...$user.links];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each $user.links as _, index}
|
||||||
|
<div class="input-group m-1">
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-up"
|
||||||
|
color="secondary"
|
||||||
|
tooltip="Move link up"
|
||||||
|
click={() => moveLink(index, true)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-down"
|
||||||
|
color="secondary"
|
||||||
|
tooltip="Move link down"
|
||||||
|
click={() => moveLink(index, false)}
|
||||||
|
/>
|
||||||
|
<input type="text" class="form-control" bind:value={$user.links[index]} />
|
||||||
|
<IconButton
|
||||||
|
color="danger"
|
||||||
|
icon="trash3"
|
||||||
|
tooltip="Remove link"
|
||||||
|
click={() => removeLink(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<form class="input-group m-1" on:submit={addLink}>
|
||||||
|
<input type="text" class="form-control" bind:value={newLink} />
|
||||||
|
<IconButton type="submit" color="success" icon="plus" tooltip="Add link" />
|
||||||
|
</form>
|
180
frontend/src/routes/@[username]/edit/other/+page.svelte
Normal file
180
frontend/src/routes/@[username]/edit/other/+page.svelte
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
|
||||||
|
import { PreferenceSize, type APIError, type MeUser } from "$lib/api/entities";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
import { Button, ButtonGroup, FormGroup, Icon, Input, InputGroup } from "sveltestrap";
|
||||||
|
import { PUBLIC_SHORT_BASE } from "$env/static/public";
|
||||||
|
import CustomPreference from "./CustomPreference.svelte";
|
||||||
|
import { DateTime, FixedOffsetZone } from "luxon";
|
||||||
|
import { addToast } from "$lib/toast";
|
||||||
|
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||||
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
|
|
||||||
|
const user = getContext<Writable<MeUser>>("user");
|
||||||
|
let error: APIError | null = null;
|
||||||
|
|
||||||
|
// Custom preferences code
|
||||||
|
let preferenceIds: string[];
|
||||||
|
$: preferenceIds = Object.keys($user.custom_preferences);
|
||||||
|
|
||||||
|
const addPreference = () => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
$user.custom_preferences[id] = {
|
||||||
|
icon: "question",
|
||||||
|
tooltip: "New preference",
|
||||||
|
size: PreferenceSize.Normal,
|
||||||
|
muted: false,
|
||||||
|
favourite: false,
|
||||||
|
};
|
||||||
|
$user.custom_preferences = $user.custom_preferences;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePreference = (id: string) => {
|
||||||
|
delete $user.custom_preferences[id];
|
||||||
|
$user.custom_preferences = $user.custom_preferences;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timezone code
|
||||||
|
let currentTime = "";
|
||||||
|
let displayTimezone = "";
|
||||||
|
$: setTime($user.timezone);
|
||||||
|
|
||||||
|
const detectTimezone = () => {
|
||||||
|
$user.timezone = DateTime.local().zoneName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTime = (timezone: string | null) => {
|
||||||
|
if (!timezone) {
|
||||||
|
currentTime = "";
|
||||||
|
displayTimezone = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = DateTime.now().setZone(timezone).offset;
|
||||||
|
const zone = FixedOffsetZone.instance(offset);
|
||||||
|
|
||||||
|
currentTime = now.setZone(zone).toLocaleString(DateTime.TIME_SIMPLE);
|
||||||
|
displayTimezone = zone.formatOffset(now.toUnixInteger(), "narrow");
|
||||||
|
};
|
||||||
|
|
||||||
|
// SID code
|
||||||
|
const now = DateTime.now().toLocal();
|
||||||
|
let canRerollSid: boolean;
|
||||||
|
$: canRerollSid = now.diff(DateTime.fromISO($user.last_sid_reroll).toLocal(), "hours").hours >= 1;
|
||||||
|
|
||||||
|
const copyShortURL = async () => {
|
||||||
|
const url = `${PUBLIC_SHORT_BASE}/${$user.sid}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
addToast({ body: "Copied the short link to your clipboard!", duration: 2000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const rerollSid = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiFetchClient<MeUser>("/users/@me/reroll");
|
||||||
|
addToast({ header: "Success", body: "Rerolled short ID!" });
|
||||||
|
error = null;
|
||||||
|
$user.sid = resp.sid;
|
||||||
|
} catch (e) {
|
||||||
|
error = e as APIError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<ErrorAlert {error} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<FormGroup floating label={'"Members" header text'}>
|
||||||
|
<Input bind:value={$user.member_title} placeholder="Members" />
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
This is the text used for the "Members" heading. If you leave it blank, the default text will
|
||||||
|
be used.
|
||||||
|
</p>
|
||||||
|
</FormGroup>
|
||||||
|
{#if PUBLIC_SHORT_BASE}
|
||||||
|
<hr />
|
||||||
|
<p>
|
||||||
|
Current short ID: <code>{$user.sid}</code>
|
||||||
|
<ButtonGroup class="mb-1">
|
||||||
|
<Button color="secondary" disabled={!canRerollSid} on:click={() => rerollSid()}
|
||||||
|
>Reroll short ID</Button
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="link-45deg"
|
||||||
|
tooltip="Copy short link"
|
||||||
|
color="secondary"
|
||||||
|
click={copyShortURL}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
<br />
|
||||||
|
<span class="text-muted">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
This ID is used in <code>prns.cc</code> links. You can reroll one short ID every hour (shared
|
||||||
|
between your main profile and all members) by pressing the button above.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={$user.list_private}
|
||||||
|
id="listPrivate"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="listPrivate">Hide member list</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
This only hides your member <em>list</em>.
|
||||||
|
<strong>
|
||||||
|
Your members will still be visible to anyone at
|
||||||
|
<code class="text-nowrap">pronouns.cc/@{$user.name}/[member-name]</code>.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<hr />
|
||||||
|
<div class="m-1">
|
||||||
|
<p class="mt-1 my-2">
|
||||||
|
You can optionally set your timezone, which will show your current local time on your
|
||||||
|
profile.
|
||||||
|
</p>
|
||||||
|
<InputGroup>
|
||||||
|
<Button on:click={detectTimezone}>Detect timezone</Button>
|
||||||
|
<Input disabled value={$user.timezone !== null ? $user.timezone : "Unset"} />
|
||||||
|
<Button on:click={() => ($user.timezone = null)}>Reset</Button>
|
||||||
|
</InputGroup>
|
||||||
|
<p class="mt-2">
|
||||||
|
{#if $user.timezone}
|
||||||
|
This will show up on your profile like this:
|
||||||
|
<Icon name="clock" aria-hidden />
|
||||||
|
{currentTime} <span class="text-body-secondary">(UTC{displayTimezone})</span>
|
||||||
|
<br />
|
||||||
|
{/if}
|
||||||
|
<span class="text-muted">
|
||||||
|
Your timezone is never shared directly, only the difference between UTC and your current
|
||||||
|
timezone is.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
Preferences <Button on:click={addPreference} color="success"
|
||||||
|
><Icon name="plus" aria-hidden /> Add new</Button
|
||||||
|
>
|
||||||
|
</h3>
|
||||||
|
{#each preferenceIds as id}
|
||||||
|
<CustomPreference
|
||||||
|
bind:preference={$user.custom_preferences[id]}
|
||||||
|
remove={() => removePreference(id)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
|
@ -8,7 +8,7 @@
|
||||||
Input,
|
Input,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "sveltestrap";
|
} from "sveltestrap";
|
||||||
import icons from "../../../icons";
|
import icons from "../../../../icons";
|
||||||
import IconButton from "$lib/components/IconButton.svelte";
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
|
||||||
export let icon = "";
|
export let icon = "";
|
84
frontend/src/routes/@[username]/edit/pronouns/+page.svelte
Normal file
84
frontend/src/routes/@[username]/edit/pronouns/+page.svelte
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import type { MeUser } from "$lib/api/entities";
|
||||||
|
import { Button, Icon, Popover } from "sveltestrap";
|
||||||
|
import EditablePronouns from "$lib/components/edit/EditablePronouns.svelte";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
const user = getContext<Writable<MeUser>>("user");
|
||||||
|
let newPronouns = "";
|
||||||
|
|
||||||
|
const movePronoun = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == $user.pronouns.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const temp = $user.pronouns[index];
|
||||||
|
$user.pronouns[index] = $user.pronouns[newIndex];
|
||||||
|
$user.pronouns[newIndex] = temp;
|
||||||
|
$user.pronouns = [...$user.pronouns];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPronouns = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (newPronouns in data.pronouns) {
|
||||||
|
const fullSet = data.pronouns[newPronouns];
|
||||||
|
$user.pronouns = [
|
||||||
|
...$user.pronouns,
|
||||||
|
{
|
||||||
|
pronouns: fullSet.pronouns.join("/"),
|
||||||
|
display_text: fullSet.display || null,
|
||||||
|
status: "okay",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$user.pronouns = [
|
||||||
|
...$user.pronouns,
|
||||||
|
{ pronouns: newPronouns, display_text: null, status: "okay" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
newPronouns = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePronoun = (index: number) => {
|
||||||
|
$user.pronouns.splice(index, 1);
|
||||||
|
$user.pronouns = [...$user.pronouns];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each $user.pronouns as _, index}
|
||||||
|
<EditablePronouns
|
||||||
|
bind:pronoun={$user.pronouns[index]}
|
||||||
|
preferences={$user.custom_preferences}
|
||||||
|
moveUp={() => movePronoun(index, true)}
|
||||||
|
moveDown={() => movePronoun(index, false)}
|
||||||
|
remove={() => removePronoun(index)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<form class="input-group m-1" on:submit={addPronouns}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="New pronouns"
|
||||||
|
bind:value={newPronouns}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
type="submit"
|
||||||
|
color="success"
|
||||||
|
icon="plus"
|
||||||
|
tooltip="Add pronouns"
|
||||||
|
disabled={newPronouns === ""}
|
||||||
|
/>
|
||||||
|
<Button id="pronouns-help" color="secondary"><Icon name="question" /></Button>
|
||||||
|
<Popover target="pronouns-help" placement="bottom">
|
||||||
|
For common pronouns, the short form (e.g. "she/her" or "he/him") is enough; for less common
|
||||||
|
pronouns, you will have to use all five forms (e.g. "ce/cir/cir/cirs/cirself").
|
||||||
|
</Popover>
|
||||||
|
</form>
|
|
@ -11,7 +11,7 @@ export const load = async ({ params }) => {
|
||||||
throw redirect(303, `/@${resp.name}`);
|
throw redirect(303, `/@${resp.name}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ((e as APIError).code === ErrorCode.UserNotFound) {
|
if ((e as APIError).code === ErrorCode.UserNotFound) {
|
||||||
throw error(404, (e as APIError).message);
|
throw error(404, e as APIError);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
|
|
39
frontend/src/routes/edit/+error.svelte
Normal file
39
frontend/src/routes/edit/+error.svelte
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { ErrorCode } from "$lib/api/entities";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Error - pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if $page.error?.code === ErrorCode.Forbidden || $page.error?.code === ErrorCode.InvalidToken}
|
||||||
|
<h1>Not logged in</h1>
|
||||||
|
<p>
|
||||||
|
Either you aren't logged in, or your login has expired. Please <a href="/auth/login"
|
||||||
|
>log in again</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
{:else if $page.error?.code === ErrorCode.NotOwnMember}
|
||||||
|
<h1>Not your member</h1>
|
||||||
|
<p>You can only edit your own members.</p>
|
||||||
|
{:else}
|
||||||
|
<h1>An error occurred ({$page.status})</h1>
|
||||||
|
|
||||||
|
{#if $page.status === 404}
|
||||||
|
<p>The user you were looking for couldn't be found. Please check for any typos.</p>
|
||||||
|
{:else if $page.status === 429}
|
||||||
|
<p>You've exceeded a rate limit, please try again later.</p>
|
||||||
|
{:else if $page.status === 500}
|
||||||
|
<p>An internal error occurred. Please try again later.</p>
|
||||||
|
<p>
|
||||||
|
If this error keeps happening, please <a
|
||||||
|
href="https://codeberg.org/pronounscc/pronouns.cc/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer">file a bug report</a
|
||||||
|
> with an explanation of what you did to cause the error.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p>Error message: <code>{$page.error?.message}</code></p>
|
||||||
|
{/if}
|
|
@ -1,808 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import {
|
|
||||||
MAX_DESCRIPTION_LENGTH,
|
|
||||||
memberAvatars,
|
|
||||||
type APIError,
|
|
||||||
type Field,
|
|
||||||
type FieldEntry,
|
|
||||||
type Member,
|
|
||||||
type Pronoun,
|
|
||||||
type PrideFlag,
|
|
||||||
} from "$lib/api/entities";
|
|
||||||
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
FormGroup,
|
|
||||||
Icon,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalFooter,
|
|
||||||
Popover,
|
|
||||||
TabContent,
|
|
||||||
TabPane,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
CardHeader,
|
|
||||||
Alert,
|
|
||||||
} from "sveltestrap";
|
|
||||||
import { DateTime } from "luxon";
|
|
||||||
import { encode } from "base64-arraybuffer";
|
|
||||||
import prettyBytes from "pretty-bytes";
|
|
||||||
import { PUBLIC_SHORT_BASE } from "$env/static/public";
|
|
||||||
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
|
|
||||||
import IconButton from "$lib/components/IconButton.svelte";
|
|
||||||
import EditableField from "../../EditableField.svelte";
|
|
||||||
import EditableName from "../../EditableName.svelte";
|
|
||||||
import EditablePronouns from "../../EditablePronouns.svelte";
|
|
||||||
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
|
||||||
import type { PageData, Snapshot } from "./$types";
|
|
||||||
import { addToast, delToast } from "$lib/toast";
|
|
||||||
import { memberNameRegex } from "$lib/api/regex";
|
|
||||||
import { charCount, renderMarkdown } from "$lib/utils";
|
|
||||||
import MarkdownHelp from "../../MarkdownHelp.svelte";
|
|
||||||
import FlagButton from "../../FlagButton.svelte";
|
|
||||||
|
|
||||||
const MAX_AVATAR_BYTES = 1_000_000;
|
|
||||||
|
|
||||||
export let data: PageData;
|
|
||||||
|
|
||||||
if (data.user.id !== data.member.user.id) {
|
|
||||||
addToast({ header: "Not your member", body: "You cannot edit another user's member." });
|
|
||||||
goto(`/@${data.member.user.name}/${data.member.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let error: APIError | null = null;
|
|
||||||
|
|
||||||
let bio: string = data.member.bio || "";
|
|
||||||
let name: string = data.member.name;
|
|
||||||
let display_name: string = data.member.display_name || "";
|
|
||||||
let links: string[] = window.structuredClone(data.member.links);
|
|
||||||
let names: FieldEntry[] = window.structuredClone(data.member.names);
|
|
||||||
let pronouns: Pronoun[] = window.structuredClone(data.member.pronouns);
|
|
||||||
let fields: Field[] = window.structuredClone(data.member.fields);
|
|
||||||
let flags: PrideFlag[] = window.structuredClone(data.member.flags);
|
|
||||||
let unlisted: boolean = data.member.unlisted || false;
|
|
||||||
|
|
||||||
let memberNameValid = true;
|
|
||||||
$: memberNameValid = memberNameRegex.test(name);
|
|
||||||
|
|
||||||
let avatar: string | null;
|
|
||||||
let avatar_files: FileList | null;
|
|
||||||
|
|
||||||
let newName = "";
|
|
||||||
let newPronouns = "";
|
|
||||||
let newLink = "";
|
|
||||||
|
|
||||||
let flagSearch = "";
|
|
||||||
let filteredFlags: PrideFlag[];
|
|
||||||
$: filteredFlags = filterFlags(flagSearch, data.flags);
|
|
||||||
|
|
||||||
const filterFlags = (search: string, flags: PrideFlag[]) => {
|
|
||||||
return (
|
|
||||||
search
|
|
||||||
? flags.filter((flag) => flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
|
||||||
: flags
|
|
||||||
).slice(0, 25);
|
|
||||||
};
|
|
||||||
|
|
||||||
let modified = false;
|
|
||||||
|
|
||||||
$: modified = isModified(
|
|
||||||
data.member,
|
|
||||||
bio,
|
|
||||||
name,
|
|
||||||
display_name,
|
|
||||||
links,
|
|
||||||
names,
|
|
||||||
pronouns,
|
|
||||||
fields,
|
|
||||||
flags,
|
|
||||||
avatar,
|
|
||||||
unlisted,
|
|
||||||
);
|
|
||||||
$: getAvatar(avatar_files).then((b64) => (avatar = b64));
|
|
||||||
|
|
||||||
const isModified = (
|
|
||||||
member: Member,
|
|
||||||
bio: string,
|
|
||||||
name: string,
|
|
||||||
display_name: string,
|
|
||||||
links: string[],
|
|
||||||
names: FieldEntry[],
|
|
||||||
pronouns: Pronoun[],
|
|
||||||
fields: Field[],
|
|
||||||
flags: PrideFlag[],
|
|
||||||
avatar: string | null,
|
|
||||||
unlisted: boolean,
|
|
||||||
) => {
|
|
||||||
if (name !== member.name) return true;
|
|
||||||
if (bio !== member.bio) return true;
|
|
||||||
if (display_name !== member.display_name) return true;
|
|
||||||
if (!linksEqual(links, member.links)) return true;
|
|
||||||
if (!fieldsEqual(fields, member.fields)) return true;
|
|
||||||
if (!flagsEqual(flags, member.flags)) return true;
|
|
||||||
if (!namesEqual(names, member.names)) return true;
|
|
||||||
if (!pronounsEqual(pronouns, member.pronouns)) return true;
|
|
||||||
if (avatar !== null) return true;
|
|
||||||
if (unlisted !== member.unlisted) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldsEqual = (arr1: Field[], arr2: Field[]) => {
|
|
||||||
if (arr1?.length !== arr2?.length) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].entries.length === arr2[i].entries.length)) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].name === arr2[i].name)) return false;
|
|
||||||
|
|
||||||
return arr1.every((_, i) =>
|
|
||||||
arr1[i].entries.every(
|
|
||||||
(entry, j) =>
|
|
||||||
entry.value === arr2[i].entries[j].value && entry.status === arr2[i].entries[j].status,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const namesEqual = (arr1: FieldEntry[], arr2: FieldEntry[]) => {
|
|
||||||
if (arr1?.length !== arr2?.length) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].value === arr2[i].value)) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].status === arr2[i].status)) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pronounsEqual = (arr1: Pronoun[], arr2: Pronoun[]) => {
|
|
||||||
if (arr1?.length !== arr2?.length) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].pronouns === arr2[i].pronouns)) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].display_text === arr2[i].display_text)) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].status === arr2[i].status)) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const linksEqual = (arr1: string[], arr2: string[]) => {
|
|
||||||
if (arr1.length !== arr2.length) return false;
|
|
||||||
return arr1.every((_, i) => arr1[i] === arr2[i]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const flagsEqual = (arr1: PrideFlag[], arr2: PrideFlag[]) => {
|
|
||||||
if (arr1.length !== arr2.length) return false;
|
|
||||||
return arr1.every((_, i) => arr1[i].id === arr2[i].id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAvatar = async (list: FileList | null) => {
|
|
||||||
if (!list || list.length === 0) return null;
|
|
||||||
if (list[0].size > MAX_AVATAR_BYTES) {
|
|
||||||
addToast({
|
|
||||||
header: "Avatar too large",
|
|
||||||
body: `This avatar is too large, please resize it (maximum is ${prettyBytes(
|
|
||||||
MAX_AVATAR_BYTES,
|
|
||||||
)}, the file you tried to upload is ${prettyBytes(list[0].size)})`,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await list[0].arrayBuffer();
|
|
||||||
const base64 = encode(buffer);
|
|
||||||
|
|
||||||
const uri = `data:${list[0].type};base64,${base64}`;
|
|
||||||
|
|
||||||
return uri;
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveName = (index: number, up: boolean) => {
|
|
||||||
if (up && index == 0) return;
|
|
||||||
if (!up && index == names.length - 1) return;
|
|
||||||
|
|
||||||
const newIndex = up ? index - 1 : index + 1;
|
|
||||||
|
|
||||||
const temp = names[index];
|
|
||||||
names[index] = names[newIndex];
|
|
||||||
names[newIndex] = temp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const movePronoun = (index: number, up: boolean) => {
|
|
||||||
if (up && index == 0) return;
|
|
||||||
if (!up && index == pronouns.length - 1) return;
|
|
||||||
|
|
||||||
const newIndex = up ? index - 1 : index + 1;
|
|
||||||
|
|
||||||
const temp = pronouns[index];
|
|
||||||
pronouns[index] = pronouns[newIndex];
|
|
||||||
pronouns[newIndex] = temp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveField = (index: number, up: boolean) => {
|
|
||||||
if (up && index == 0) return;
|
|
||||||
if (!up && index == fields.length - 1) return;
|
|
||||||
|
|
||||||
const newIndex = up ? index - 1 : index + 1;
|
|
||||||
|
|
||||||
const temp = fields[index];
|
|
||||||
fields[index] = fields[newIndex];
|
|
||||||
fields[newIndex] = temp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveLink = (index: number, up: boolean) => {
|
|
||||||
if (up && index == 0) return;
|
|
||||||
if (!up && index == links.length - 1) return;
|
|
||||||
|
|
||||||
const newIndex = up ? index - 1 : index + 1;
|
|
||||||
|
|
||||||
const temp = links[index];
|
|
||||||
links[index] = links[newIndex];
|
|
||||||
links[newIndex] = temp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveFlag = (index: number, up: boolean) => {
|
|
||||||
if (up && index == 0) return;
|
|
||||||
if (!up && index == flags.length - 1) return;
|
|
||||||
|
|
||||||
const newIndex = up ? index - 1 : index + 1;
|
|
||||||
|
|
||||||
const temp = flags[index];
|
|
||||||
flags[index] = flags[newIndex];
|
|
||||||
flags[newIndex] = temp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addFlag = (flag: PrideFlag) => {
|
|
||||||
flags = [...flags, flag];
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFlag = (index: number) => {
|
|
||||||
flags.splice(index, 1);
|
|
||||||
flags = [...flags];
|
|
||||||
};
|
|
||||||
|
|
||||||
const addName = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
names = [...names, { value: newName, status: "okay" }];
|
|
||||||
newName = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const addPronouns = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (newPronouns in data.pronouns) {
|
|
||||||
const fullSet = data.pronouns[newPronouns];
|
|
||||||
pronouns = [
|
|
||||||
...pronouns,
|
|
||||||
{
|
|
||||||
pronouns: fullSet.pronouns.join("/"),
|
|
||||||
display_text: fullSet.display || null,
|
|
||||||
status: "okay",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
pronouns = [...pronouns, { pronouns: newPronouns, display_text: null, status: "okay" }];
|
|
||||||
}
|
|
||||||
newPronouns = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const addLink = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
links = [...links, newLink];
|
|
||||||
newLink = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeName = (index: number) => {
|
|
||||||
names.splice(index, 1);
|
|
||||||
names = [...names];
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePronoun = (index: number) => {
|
|
||||||
pronouns.splice(index, 1);
|
|
||||||
pronouns = [...pronouns];
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeLink = (index: number) => {
|
|
||||||
links.splice(index, 1);
|
|
||||||
links = [...links];
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeField = (index: number) => {
|
|
||||||
fields.splice(index, 1);
|
|
||||||
fields = [...fields];
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateMember = async () => {
|
|
||||||
const toastId = addToast({
|
|
||||||
header: "Saving changes",
|
|
||||||
body: "Saving changes, please wait...",
|
|
||||||
duration: -1,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await apiFetchClient<Member>(`/members/${data.member.id}`, "PATCH", {
|
|
||||||
name,
|
|
||||||
display_name,
|
|
||||||
avatar,
|
|
||||||
bio,
|
|
||||||
links,
|
|
||||||
names,
|
|
||||||
pronouns,
|
|
||||||
fields,
|
|
||||||
flags: flags.map((flag) => flag.id),
|
|
||||||
unlisted,
|
|
||||||
});
|
|
||||||
|
|
||||||
addToast({ header: "Success", body: "Successfully saved changes!" });
|
|
||||||
|
|
||||||
data.member = resp;
|
|
||||||
avatar = null;
|
|
||||||
error = null;
|
|
||||||
} catch (e) {
|
|
||||||
error = e as APIError;
|
|
||||||
} finally {
|
|
||||||
delToast(toastId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteMember = async () => {
|
|
||||||
try {
|
|
||||||
await fastFetchClient(`/members/${data.member.id}`, "DELETE");
|
|
||||||
|
|
||||||
toggleDeleteOpen();
|
|
||||||
addToast({
|
|
||||||
header: "Deleted member",
|
|
||||||
body: `Successfully deleted member ${data.member.name}!`,
|
|
||||||
});
|
|
||||||
goto(`/@${data.member.user.name}`);
|
|
||||||
} catch (e) {
|
|
||||||
deleteName = "";
|
|
||||||
deleteError = e as APIError;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let deleteModalPronoun = "the member's";
|
|
||||||
$: deleteModalPronoun = updateModalPronoun(pronouns);
|
|
||||||
|
|
||||||
const updateModalPronoun = (pronouns: Pronoun[]) => {
|
|
||||||
const filtered = pronouns.filter((entry) => entry.status === "favourite");
|
|
||||||
if (filtered.length < 1) return "the member's";
|
|
||||||
|
|
||||||
const split = filtered[0].pronouns.split("/");
|
|
||||||
if (split.length !== 5) return "the member's";
|
|
||||||
return split[2];
|
|
||||||
};
|
|
||||||
|
|
||||||
let deleteOpen = false;
|
|
||||||
const toggleDeleteOpen = () => (deleteOpen = !deleteOpen);
|
|
||||||
let deleteName = "";
|
|
||||||
let deleteError: APIError | null = null;
|
|
||||||
|
|
||||||
const now = DateTime.now().toLocal();
|
|
||||||
let canRerollSid: boolean;
|
|
||||||
$: canRerollSid =
|
|
||||||
now.diff(DateTime.fromISO(data.user.last_sid_reroll).toLocal(), "hours").hours >= 1;
|
|
||||||
|
|
||||||
const rerollSid = async () => {
|
|
||||||
try {
|
|
||||||
const resp = await apiFetchClient<Member>(`/members/${data.member.id}/reroll`);
|
|
||||||
addToast({ header: "Success", body: "Rerolled short ID!" });
|
|
||||||
error = null;
|
|
||||||
data.member.sid = resp.sid;
|
|
||||||
} catch (e) {
|
|
||||||
error = e as APIError;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyShortURL = async () => {
|
|
||||||
const url = `${PUBLIC_SHORT_BASE}/${data.member.sid}`;
|
|
||||||
await navigator.clipboard.writeText(url);
|
|
||||||
addToast({ body: "Copied the short link to your clipboard!", duration: 2000 });
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SnapshotData {
|
|
||||||
bio: string;
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
links: string[];
|
|
||||||
names: FieldEntry[];
|
|
||||||
pronouns: Pronoun[];
|
|
||||||
fields: Field[];
|
|
||||||
flags: PrideFlag[];
|
|
||||||
unlisted: boolean;
|
|
||||||
|
|
||||||
avatar: string | null;
|
|
||||||
newName: string;
|
|
||||||
newPronouns: string;
|
|
||||||
newLink: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const snapshot: Snapshot<SnapshotData> = {
|
|
||||||
capture: () => ({
|
|
||||||
bio,
|
|
||||||
name,
|
|
||||||
display_name,
|
|
||||||
links,
|
|
||||||
names,
|
|
||||||
pronouns,
|
|
||||||
fields,
|
|
||||||
flags,
|
|
||||||
unlisted,
|
|
||||||
avatar,
|
|
||||||
newName,
|
|
||||||
newPronouns,
|
|
||||||
newLink,
|
|
||||||
}),
|
|
||||||
restore: (value) => {
|
|
||||||
bio = value.bio;
|
|
||||||
name = value.name;
|
|
||||||
display_name = value.display_name;
|
|
||||||
links = value.links;
|
|
||||||
names = value.names;
|
|
||||||
pronouns = value.pronouns;
|
|
||||||
fields = value.fields;
|
|
||||||
flags = value.flags;
|
|
||||||
unlisted = value.unlisted;
|
|
||||||
avatar = value.avatar;
|
|
||||||
newName = value.newName;
|
|
||||||
newPronouns = value.newPronouns;
|
|
||||||
newLink = value.newLink;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Edit member profile - pronouns.cc</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1>
|
|
||||||
Edit member profile
|
|
||||||
<ButtonGroup>
|
|
||||||
<IconButton
|
|
||||||
color="secondary"
|
|
||||||
icon="chevron-left"
|
|
||||||
href="/@{data.member.user.name}/{data.member.name}"
|
|
||||||
tooltip="Back to member"
|
|
||||||
/>
|
|
||||||
{#if modified}
|
|
||||||
<Button color="success" on:click={() => updateMember()} disabled={!memberNameValid}
|
|
||||||
>Save changes</Button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<Button color="danger" on:click={toggleDeleteOpen}
|
|
||||||
>Delete {data.member.display_name ?? data.member.name}</Button
|
|
||||||
>
|
|
||||||
</ButtonGroup>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<Modal header="Delete member" isOpen={deleteOpen} toggle={toggleDeleteOpen}>
|
|
||||||
<ModalBody>
|
|
||||||
<p>
|
|
||||||
If you want to delete this member, type {deleteModalPronoun} name (<code
|
|
||||||
>{data.member.name}</code
|
|
||||||
>) below:
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<input type="text" class="form-control" bind:value={deleteName} />
|
|
||||||
</p>
|
|
||||||
{#if deleteError}
|
|
||||||
<ErrorAlert error={deleteError} />
|
|
||||||
{/if}
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button color="danger" disabled={deleteName !== data.member.name} on:click={deleteMember}>
|
|
||||||
Delete member
|
|
||||||
</Button>
|
|
||||||
<Button color="secondary" on:click={toggleDeleteOpen}>Cancel</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<ErrorAlert {error} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<TabContent>
|
|
||||||
<TabPane tabId="avatar" tab="Names and avatar" active>
|
|
||||||
<div class="row mt-3">
|
|
||||||
<div class="col-md">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md text-center">
|
|
||||||
{#if avatar === ""}
|
|
||||||
<FallbackImage alt="Current avatar" urls={[]} width={200} />
|
|
||||||
{:else if avatar}
|
|
||||||
<img
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
src={avatar}
|
|
||||||
alt="New avatar"
|
|
||||||
class="rounded-circle img-fluid"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<FallbackImage alt="Current avatar" urls={memberAvatars(data.member)} width={200} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="col-md">
|
|
||||||
<input
|
|
||||||
class="form-control"
|
|
||||||
id="avatar"
|
|
||||||
type="file"
|
|
||||||
bind:files={avatar_files}
|
|
||||||
accept="image/png, image/jpeg, image/gif, image/webp"
|
|
||||||
/>
|
|
||||||
<p class="text-muted mt-3">
|
|
||||||
<Icon name="info-circle-fill" aria-hidden /> Only PNG, JPEG, GIF, and WebP images can be
|
|
||||||
used as avatars. Avatars cannot be larger than 1 MB, and animated avatars will be made
|
|
||||||
static.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<!-- svelte-ignore a11y-invalid-attribute -->
|
|
||||||
<a href="" on:click={() => (avatar = "")}>Remove avatar</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md">
|
|
||||||
<FormGroup floating label="Name">
|
|
||||||
<Input bind:value={name} />
|
|
||||||
<p class="text-muted mt-1">
|
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
|
||||||
The member name is only used as part of the link to their profile page.
|
|
||||||
</p>
|
|
||||||
</FormGroup>
|
|
||||||
{#if !memberNameValid}
|
|
||||||
<p class="text-danger-emphasis mb-2">That member name is not valid.</p>
|
|
||||||
{/if}
|
|
||||||
<FormGroup floating label="Display name">
|
|
||||||
<Input bind:value={display_name} />
|
|
||||||
</FormGroup>
|
|
||||||
<p class="text-muted mt-1">
|
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
|
||||||
Your display name is used in page titles and as a header.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4>Names</h4>
|
|
||||||
{#each names as _, index}
|
|
||||||
<EditableName
|
|
||||||
bind:value={names[index].value}
|
|
||||||
bind:status={names[index].status}
|
|
||||||
preferences={data.user.custom_preferences}
|
|
||||||
moveUp={() => moveName(index, true)}
|
|
||||||
moveDown={() => moveName(index, false)}
|
|
||||||
remove={() => removeName(index)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
<form class="input-group m-1" on:submit={addName}>
|
|
||||||
<input type="text" class="form-control" bind:value={newName} />
|
|
||||||
<IconButton type="submit" color="success" icon="plus" tooltip="Add name" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="bio" tab="Bio">
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="form">
|
|
||||||
<textarea class="form-control" style="height: 200px;" bind:value={bio} />
|
|
||||||
</div>
|
|
||||||
<p class="text-muted mt-1">
|
|
||||||
Using {charCount(bio)}/{MAX_DESCRIPTION_LENGTH} characters
|
|
||||||
</p>
|
|
||||||
<p class="text-muted my-2">
|
|
||||||
<MarkdownHelp />
|
|
||||||
</p>
|
|
||||||
{#if bio}
|
|
||||||
<hr />
|
|
||||||
<Card>
|
|
||||||
<CardHeader>Preview</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
{@html renderMarkdown(bio)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="pronouns" tab="Pronouns">
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="col-md">
|
|
||||||
{#each pronouns as _, index}
|
|
||||||
<EditablePronouns
|
|
||||||
bind:pronoun={pronouns[index]}
|
|
||||||
preferences={data.user.custom_preferences}
|
|
||||||
moveUp={() => movePronoun(index, true)}
|
|
||||||
moveDown={() => movePronoun(index, false)}
|
|
||||||
remove={() => removePronoun(index)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
<form class="input-group m-1" on:submit={addPronouns}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="New pronouns"
|
|
||||||
bind:value={newPronouns}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
type="submit"
|
|
||||||
color="success"
|
|
||||||
icon="plus"
|
|
||||||
tooltip="Add pronouns"
|
|
||||||
disabled={newPronouns === ""}
|
|
||||||
/>
|
|
||||||
<Button id="pronouns-help" color="secondary"><Icon name="question" /></Button>
|
|
||||||
<Popover target="pronouns-help" placement="bottom">
|
|
||||||
For common pronouns, the short form (e.g. "she/her" or "he/him") is enough; for less
|
|
||||||
common pronouns, you will have to use all five forms (e.g. "ce/cir/cir/cirs/cirself").
|
|
||||||
</Popover>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="fields" tab="Fields">
|
|
||||||
{#if data.member.fields.length === 0}
|
|
||||||
<Alert class="mt-3" color="secondary" fade={false}>
|
|
||||||
Fields are extra categories you can add separate from names and pronouns.<br />
|
|
||||||
For example, you could use them for gender terms, honorifics, or compliments.
|
|
||||||
</Alert>
|
|
||||||
{/if}
|
|
||||||
<div class="grid gap-3">
|
|
||||||
<div class="row row-cols-1 row-cols-md-2">
|
|
||||||
{#each fields as _, index}
|
|
||||||
<EditableField
|
|
||||||
bind:field={fields[index]}
|
|
||||||
preferences={data.user.custom_preferences}
|
|
||||||
deleteField={() => removeField(index)}
|
|
||||||
moveField={(up) => moveField(index, up)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button on:click={() => (fields = [...fields, { name: "New field", entries: [] }])}>
|
|
||||||
<Icon name="plus" aria-hidden /> Add new field
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="flags" tab="Flags">
|
|
||||||
<div class="mt-3">
|
|
||||||
{#each flags as _, index}
|
|
||||||
<ButtonGroup class="m-1">
|
|
||||||
<IconButton
|
|
||||||
icon="chevron-left"
|
|
||||||
color="secondary"
|
|
||||||
tooltip="Move flag to the left"
|
|
||||||
click={() => moveFlag(index, true)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon="chevron-right"
|
|
||||||
color="secondary"
|
|
||||||
tooltip="Move flag to the right"
|
|
||||||
click={() => moveFlag(index, false)}
|
|
||||||
/>
|
|
||||||
<FlagButton
|
|
||||||
flag={flags[index]}
|
|
||||||
tooltip="Remove this flag from your profile"
|
|
||||||
on:click={() => removeFlag(index)}
|
|
||||||
/>
|
|
||||||
</ButtonGroup>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md">
|
|
||||||
<Input
|
|
||||||
placeholder="Filter flags"
|
|
||||||
bind:value={flagSearch}
|
|
||||||
disabled={data.flags.length === 0}
|
|
||||||
/>
|
|
||||||
<div class="p-2">
|
|
||||||
{#each filteredFlags as flag (flag.id)}
|
|
||||||
<FlagButton
|
|
||||||
{flag}
|
|
||||||
tooltip="Add this flag to your profile"
|
|
||||||
on:click={() => addFlag(flag)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
{#if data.flags.length === 0}
|
|
||||||
You haven't uploaded any flags yet.
|
|
||||||
{:else}
|
|
||||||
There are no flags matching your search <strong>{flagSearch}</strong>.
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md">
|
|
||||||
<Alert color="secondary" fade={false}>
|
|
||||||
{#if data.flags.length === 0}
|
|
||||||
<p><strong>Why can't I see any flags?</strong></p>
|
|
||||||
<p>
|
|
||||||
There are thousands of pride flags, and it would be impossible to bundle all of them
|
|
||||||
by default. Many labels also have multiple different flags that are favoured by
|
|
||||||
different people. Because of this, there are no flags available by default--instead,
|
|
||||||
you can upload flags in your <a href="/settings/flags">settings</a>. Your main profile
|
|
||||||
and your member profiles can all have different flags.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
To upload and delete flags, go to your <a href="/settings/flags">settings</a>.
|
|
||||||
{/if}
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="links" tab="Links">
|
|
||||||
<div class="mt-3">
|
|
||||||
{#each links as _, index}
|
|
||||||
<div class="input-group m-1">
|
|
||||||
<IconButton
|
|
||||||
icon="chevron-up"
|
|
||||||
color="secondary"
|
|
||||||
tooltip="Move link up"
|
|
||||||
click={() => moveLink(index, true)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon="chevron-down"
|
|
||||||
color="secondary"
|
|
||||||
tooltip="Move link down"
|
|
||||||
click={() => moveLink(index, false)}
|
|
||||||
/>
|
|
||||||
<input type="text" class="form-control" bind:value={links[index]} />
|
|
||||||
<IconButton
|
|
||||||
color="danger"
|
|
||||||
icon="trash3"
|
|
||||||
tooltip="Remove link"
|
|
||||||
click={() => removeLink(index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<form class="input-group m-1" on:submit={addLink}>
|
|
||||||
<input type="text" class="form-control" bind:value={newLink} />
|
|
||||||
<IconButton type="submit" color="success" icon="plus" tooltip="Add link" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="other" tab="Other">
|
|
||||||
<div class="row mt-3">
|
|
||||||
<div class="col-md">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" bind:checked={unlisted} id="unlisted" />
|
|
||||||
<label class="form-check-label" for="unlisted">Hide from member list</label>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted mt-1">
|
|
||||||
{#if data.user.list_private}
|
|
||||||
<Icon name="exclamation-triangle-fill" aria-hidden />
|
|
||||||
Your member list is currently hidden, so <strong>this setting has no effect</strong>. If
|
|
||||||
you want to make your member list visible again,
|
|
||||||
<a href="/edit/profile">edit your user profile</a>.
|
|
||||||
<br />
|
|
||||||
{/if}
|
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
|
||||||
This <em>only</em> hides this member from your member list.
|
|
||||||
<strong>
|
|
||||||
This member will still be visible to anyone at
|
|
||||||
<code class="text-nowrap">pronouns.cc/@{data.user.name}/{data.member.name}</code>.
|
|
||||||
</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{#if PUBLIC_SHORT_BASE}
|
|
||||||
<div class="col-md">
|
|
||||||
<p>
|
|
||||||
Current short ID: <code>{data.member.sid}</code>
|
|
||||||
<ButtonGroup class="mb-1">
|
|
||||||
<Button color="secondary" disabled={!canRerollSid} on:click={() => rerollSid()}
|
|
||||||
>Reroll short ID</Button
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon="link-45deg"
|
|
||||||
tooltip="Copy short link"
|
|
||||||
color="secondary"
|
|
||||||
click={copyShortURL}
|
|
||||||
/>
|
|
||||||
</ButtonGroup>
|
|
||||||
<br />
|
|
||||||
<span class="text-muted">
|
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
|
||||||
This ID is used in <code>prns.cc</code> links. You can reroll one short ID every hour (shared
|
|
||||||
between your main profile and all members) by pressing the button above.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
</TabContent>
|
|
|
@ -1,9 +1,6 @@
|
||||||
import type { PrideFlag, MeUser, APIError, Member, PronounsJson } from "$lib/api/entities";
|
import { ErrorCode, type APIError, type Member, type MeUser } from "$lib/api/entities";
|
||||||
import { apiFetchClient } from "$lib/api/fetch";
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
import { error } from "@sveltejs/kit";
|
import { error, redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
import pronounsRaw from "$lib/pronouns.json";
|
|
||||||
const pronouns = pronounsRaw as PronounsJson;
|
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
|
@ -11,15 +8,24 @@ export const load = async ({ params }) => {
|
||||||
try {
|
try {
|
||||||
const user = await apiFetchClient<MeUser>(`/users/@me`);
|
const user = await apiFetchClient<MeUser>(`/users/@me`);
|
||||||
const member = await apiFetchClient<Member>(`/members/${params.id}`);
|
const member = await apiFetchClient<Member>(`/members/${params.id}`);
|
||||||
const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
|
|
||||||
|
|
||||||
return {
|
if (user.id !== member.user.id) {
|
||||||
user,
|
throw {
|
||||||
member,
|
code: ErrorCode.NotOwnMember,
|
||||||
pronouns: pronouns.autocomplete,
|
message: "You can only edit your own members.",
|
||||||
flags,
|
} as APIError;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
throw redirect(303, `/@${user.name}/${member.name}/edit`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw error((e as APIError).code, (e as APIError).message);
|
if (
|
||||||
|
(e as APIError).code === ErrorCode.Forbidden ||
|
||||||
|
(e as APIError).code === ErrorCode.InvalidToken ||
|
||||||
|
(e as APIError).code === ErrorCode.NotOwnMember
|
||||||
|
) {
|
||||||
|
throw error(403, e as APIError);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,862 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
MAX_DESCRIPTION_LENGTH,
|
|
||||||
userAvatars,
|
|
||||||
type APIError,
|
|
||||||
type Field,
|
|
||||||
type FieldEntry,
|
|
||||||
type MeUser,
|
|
||||||
type Pronoun,
|
|
||||||
PreferenceSize,
|
|
||||||
type CustomPreferences,
|
|
||||||
type PrideFlag,
|
|
||||||
} from "$lib/api/entities";
|
|
||||||
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
|
||||||
import { userStore } from "$lib/store";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
CardHeader,
|
|
||||||
FormGroup,
|
|
||||||
InputGroup,
|
|
||||||
Icon,
|
|
||||||
Input,
|
|
||||||
Popover,
|
|
||||||
TabContent,
|
|
||||||
TabPane,
|
|
||||||
InputGroupText,
|
|
||||||
} from "sveltestrap";
|
|
||||||
import { encode } from "base64-arraybuffer";
|
|
||||||
import { DateTime, FixedOffsetZone } from "luxon";
|
|
||||||
import { apiFetchClient } from "$lib/api/fetch";
|
|
||||||
import { PUBLIC_SHORT_BASE } from "$env/static/public";
|
|
||||||
import IconButton from "$lib/components/IconButton.svelte";
|
|
||||||
import EditableField from "../EditableField.svelte";
|
|
||||||
import EditableName from "../EditableName.svelte";
|
|
||||||
import EditablePronouns from "../EditablePronouns.svelte";
|
|
||||||
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
|
||||||
import { addToast, delToast } from "$lib/toast";
|
|
||||||
import type { PageData, Snapshot } from "./$types";
|
|
||||||
import { charCount, renderMarkdown } from "$lib/utils";
|
|
||||||
import MarkdownHelp from "../MarkdownHelp.svelte";
|
|
||||||
import prettyBytes from "pretty-bytes";
|
|
||||||
import CustomPreference from "./CustomPreference.svelte";
|
|
||||||
import FlagButton from "../FlagButton.svelte";
|
|
||||||
|
|
||||||
const MAX_AVATAR_BYTES = 1_000_000;
|
|
||||||
|
|
||||||
export let data: PageData;
|
|
||||||
|
|
||||||
let error: APIError | null = null;
|
|
||||||
|
|
||||||
let bio: string = data.user.bio || "";
|
|
||||||
let display_name: string = data.user.display_name || "";
|
|
||||||
let member_title: string = data.user.member_title || "";
|
|
||||||
let links: string[] = window.structuredClone(data.user.links);
|
|
||||||
let names: FieldEntry[] = window.structuredClone(data.user.names);
|
|
||||||
let pronouns: Pronoun[] = window.structuredClone(data.user.pronouns);
|
|
||||||
let fields: Field[] = window.structuredClone(data.user.fields);
|
|
||||||
let flags: PrideFlag[] = window.structuredClone(data.user.flags);
|
|
||||||
let list_private = data.user.list_private;
|
|
||||||
let custom_preferences = window.structuredClone(data.user.custom_preferences);
|
|
||||||
let timezone = data.user.timezone;
|
|
||||||
|
|
||||||
let avatar: string | null;
|
|
||||||
let avatar_files: FileList | null;
|
|
||||||
|
|
||||||
let newName = "";
|
|
||||||
let newPronouns = "";
|
|
||||||
let newLink = "";
|
|
||||||
|
|
||||||
let flagSearch = "";
|
|
||||||
let filteredFlags: PrideFlag[];
|
|
||||||
$: filteredFlags = filterFlags(flagSearch, data.flags);
|
|
||||||
|
|
||||||
const filterFlags = (search: string, flags: PrideFlag[]) => {
|
|
||||||
return (
|
|
||||||
search
|
|
||||||
? flags.filter((flag) => flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
|
||||||
: flags
|
|
||||||
).slice(0, 25);
|
|
||||||
};
|
|
||||||
|
|
||||||
let preferenceIds: string[];
|
|
||||||
$: preferenceIds = Object.keys(custom_preferences);
|
|
||||||
|
|
||||||
let modified = false;
|
|
||||||
|
|
||||||
$: modified = isModified(
|
|
||||||
data.user,
|
|
||||||
bio,
|
|
||||||
display_name,
|
|
||||||
links,
|
|
||||||
names,
|
|
||||||
pronouns,
|
|
||||||
fields,
|
|
||||||
flags,
|
|
||||||
avatar,
|
|
||||||
member_title,
|
|
||||||
list_private,
|
|
||||||
custom_preferences,
|
|
||||||
timezone,
|
|
||||||
);
|
|
||||||
$: getAvatar(avatar_files).then((b64) => (avatar = b64));
|
|
||||||
|
|
||||||
const isModified = (
|
|
||||||
user: MeUser,
|
|
||||||
bio: string,
|
|
||||||
display_name: string,
|
|
||||||
links: string[],
|
|
||||||
names: FieldEntry[],
|
|
||||||
pronouns: Pronoun[],
|
|
||||||
fields: Field[],
|
|
||||||
flags: PrideFlag[],
|
|
||||||
avatar: string | null,
|
|
||||||
member_title: string,
|
|
||||||
list_private: boolean,
|
|
||||||
custom_preferences: CustomPreferences,
|
|
||||||
timezone: string | null,
|
|
||||||
) => {
|
|
||||||
if (bio !== (user.bio || "")) return true;
|
|
||||||
if (display_name !== (user.display_name || "")) return true;
|
|
||||||
if (member_title !== (user.member_title || "")) return true;
|
|
||||||
if (!linksEqual(links, user.links)) return true;
|
|
||||||
if (!fieldsEqual(fields, user.fields)) return true;
|
|
||||||
if (!flagsEqual(flags, user.flags)) return true;
|
|
||||||
if (!namesEqual(names, user.names)) return true;
|
|
||||||
if (!pronounsEqual(pronouns, user.pronouns)) return true;
|
|
||||||
if (!customPreferencesEqual(custom_preferences, user.custom_preferences)) return true;
|
|
||||||
if (avatar !== null) return true;
|
|
||||||
if (list_private !== user.list_private) return true;
|
|
||||||
if (timezone !== user.timezone) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldsEqual = (arr1: Field[], arr2: Field[]) => {
|
|
||||||
if (arr1?.length !== arr2?.length) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].entries.length === arr2[i].entries.length)) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].name === arr2[i].name)) return false;
|
|
||||||
|
|
||||||
return arr1.every((_, i) =>
|
|
||||||
arr1[i].entries.every(
|
|
||||||
(entry, j) =>
|
|
||||||
entry.value === arr2[i].entries[j].value && entry.status === arr2[i].entries[j].status,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const namesEqual = (arr1: FieldEntry[], arr2: FieldEntry[]) => {
|
|
||||||
if (arr1?.length !== arr2?.length) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].value === arr2[i].value)) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].status === arr2[i].status)) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pronounsEqual = (arr1: Pronoun[], arr2: Pronoun[]) => {
|
|
||||||
if (arr1?.length !== arr2?.length) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].pronouns === arr2[i].pronouns)) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].display_text === arr2[i].display_text)) return false;
|
|
||||||
if (!arr1.every((_, i) => arr1[i].status === arr2[i].status)) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const linksEqual = (arr1: string[], arr2: string[]) => {
|
|
||||||
if (arr1.length !== arr2.length) return false;
|
|
||||||
return arr1.every((_, i) => arr1[i] === arr2[i]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const flagsEqual = (arr1: PrideFlag[], arr2: PrideFlag[]) => {
|
|
||||||
if (arr1.length !== arr2.length) return false;
|
|
||||||
return arr1.every((_, i) => arr1[i].id === arr2[i].id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const customPreferencesEqual = (obj1: CustomPreferences, obj2: CustomPreferences) => {
|
|
||||||
if (Object.keys(obj2).some((key) => !(key in obj1))) return false;
|
|
||||||
|
|
||||||
return Object.keys(obj1)
|
|
||||||
.map((key) => {
|
|
||||||
if (!(key in obj2)) return false;
|
|
||||||
return (
|
|
||||||
obj1[key].icon === obj2[key].icon &&
|
|
||||||
obj1[key].tooltip === obj2[key].tooltip &&
|
|
||||||
obj1[key].favourite === obj2[key].favourite &&
|
|
||||||
obj1[key].muted === obj2[key].muted &&
|
|
||||||
obj1[key].size === obj2[key].size
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.every((entry) => entry);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAvatar = async (list: FileList | null) => {
|
|
||||||
if (!list || list.length === 0) return null;
|
|
||||||
if (list[0].size > MAX_AVATAR_BYTES) {
|
|
||||||
addToast({
|
|
||||||
header: "Avatar too large",
|
|
||||||
body: `This avatar is too large, please resize it (maximum is ${prettyBytes(
|
|
||||||
MAX_AVATAR_BYTES,
|
|
||||||
)}, the file you tried to upload is ${prettyBytes(list[0].size)})`,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await list[0].arrayBuffer();
|
|
||||||
const base64 = encode(buffer);
|
|
||||||
|
|
||||||
const uri = `data:${list[0].type};base64,${base64}`;
|
|
||||||
console.log(uri.slice(0, 128));
|
|
||||||
|
|
||||||
return uri;
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentTime = "";
|
|
||||||
let displayTimezone = "";
|
|
||||||
$: setTime(timezone);
|
|
||||||
|
|
||||||
const setTime = (timezone: string | null) => {
|
|
||||||
if (!timezone) {
|
|
||||||
currentTime = "";
|
|
||||||
displayTimezone = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = DateTime.now().setZone(timezone).offset;
|
|
||||||
const zone = FixedOffsetZone.instance(offset);
|
|
||||||
|
|
||||||
currentTime = now.setZone(zone).toLocaleString(DateTime.TIME_SIMPLE);
|
|
||||||
displayTimezone = zone.formatOffset(now.toUnixInteger(), "narrow");
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveName = (index: number, up: boolean) => {
|
|
||||||
if (up && index == 0) return;
|
|
||||||
if (!up && index == names.length - 1) return;
|
|
||||||
|
|
||||||
const newIndex = up ? index - 1 : index + 1;
|
|
||||||
|
|
||||||
const temp = names[index];
|
|
||||||
names[index] = names[newIndex];
|
|
||||||
names[newIndex] = temp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const movePronoun = (index: number, up: boolean) => {
|
|
||||||
if (up && index == 0) return;
|
|
||||||
if (!up && index == pronouns.length - 1) return;
|
|
||||||
|
|
||||||
const newIndex = up ? index - 1 : index + 1;
|
|
||||||
|
|
||||||
const temp = pronouns[index];
|
|
||||||
pronouns[index] = pronouns[newIndex];
|
|
||||||
pronouns[newIndex] = temp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveField = (index: number, up: boolean) => {
|
|
||||||
if (up && index == 0) return;
|
|
||||||
if (!up && index == fields.length - 1) return;
|
|
||||||
|
|
||||||
const newIndex = up ? index - 1 : index + 1;
|
|
||||||
|
|
||||||
const temp = fields[index];
|
|
||||||
fields[index] = fields[newIndex];
|
|
||||||
fields[newIndex] = temp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveLink = (index: number, up: boolean) => {
|
|
||||||
if (up && index == 0) return;
|
|
||||||
if (!up && index == links.length - 1) return;
|
|
||||||
|
|
||||||
const newIndex = up ? index - 1 : index + 1;
|
|
||||||
|
|
||||||
const temp = links[index];
|
|
||||||
links[index] = links[newIndex];
|
|
||||||
links[newIndex] = temp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveFlag = (index: number, up: boolean) => {
|
|
||||||
if (up && index == 0) return;
|
|
||||||
if (!up && index == flags.length - 1) return;
|
|
||||||
|
|
||||||
const newIndex = up ? index - 1 : index + 1;
|
|
||||||
|
|
||||||
const temp = flags[index];
|
|
||||||
flags[index] = flags[newIndex];
|
|
||||||
flags[newIndex] = temp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addFlag = (flag: PrideFlag) => {
|
|
||||||
flags = [...flags, flag];
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFlag = (index: number) => {
|
|
||||||
flags.splice(index, 1);
|
|
||||||
flags = [...flags];
|
|
||||||
};
|
|
||||||
|
|
||||||
const addName = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
names = [...names, { value: newName, status: "okay" }];
|
|
||||||
newName = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const addPronouns = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (newPronouns in data.pronouns) {
|
|
||||||
const fullSet = data.pronouns[newPronouns];
|
|
||||||
pronouns = [
|
|
||||||
...pronouns,
|
|
||||||
{
|
|
||||||
pronouns: fullSet.pronouns.join("/"),
|
|
||||||
display_text: fullSet.display || null,
|
|
||||||
status: "okay",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
pronouns = [...pronouns, { pronouns: newPronouns, display_text: null, status: "okay" }];
|
|
||||||
}
|
|
||||||
newPronouns = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const addLink = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
links = [...links, newLink];
|
|
||||||
newLink = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const addPreference = () => {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
|
|
||||||
custom_preferences[id] = {
|
|
||||||
icon: "question",
|
|
||||||
tooltip: "New preference",
|
|
||||||
size: PreferenceSize.Normal,
|
|
||||||
muted: false,
|
|
||||||
favourite: false,
|
|
||||||
};
|
|
||||||
custom_preferences = custom_preferences;
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeName = (index: number) => {
|
|
||||||
names.splice(index, 1);
|
|
||||||
names = [...names];
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePronoun = (index: number) => {
|
|
||||||
pronouns.splice(index, 1);
|
|
||||||
pronouns = [...pronouns];
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeLink = (index: number) => {
|
|
||||||
links.splice(index, 1);
|
|
||||||
links = [...links];
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeField = (index: number) => {
|
|
||||||
fields.splice(index, 1);
|
|
||||||
fields = [...fields];
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePreference = (id: string) => {
|
|
||||||
delete custom_preferences[id];
|
|
||||||
custom_preferences = custom_preferences;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateUser = async () => {
|
|
||||||
const toastId = addToast({
|
|
||||||
header: "Saving changes",
|
|
||||||
body: "Saving changes, please wait...",
|
|
||||||
duration: -1,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await apiFetchClient<MeUser>("/users/@me", "PATCH", {
|
|
||||||
display_name,
|
|
||||||
avatar,
|
|
||||||
bio,
|
|
||||||
links,
|
|
||||||
names,
|
|
||||||
pronouns,
|
|
||||||
fields,
|
|
||||||
member_title,
|
|
||||||
list_private,
|
|
||||||
timezone: timezone || "",
|
|
||||||
custom_preferences,
|
|
||||||
flags: flags.map((flag) => flag.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
data.user = resp;
|
|
||||||
custom_preferences = resp.custom_preferences;
|
|
||||||
userStore.set(resp);
|
|
||||||
localStorage.setItem("pronouns-user", JSON.stringify(resp));
|
|
||||||
|
|
||||||
addToast({ header: "Success", body: "Successfully saved changes!" });
|
|
||||||
|
|
||||||
avatar = null;
|
|
||||||
error = null;
|
|
||||||
} catch (e) {
|
|
||||||
error = e as APIError;
|
|
||||||
} finally {
|
|
||||||
delToast(toastId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const now = DateTime.now().toLocal();
|
|
||||||
let canRerollSid: boolean;
|
|
||||||
$: canRerollSid =
|
|
||||||
now.diff(DateTime.fromISO(data.user.last_sid_reroll).toLocal(), "hours").hours >= 1;
|
|
||||||
|
|
||||||
const rerollSid = async () => {
|
|
||||||
try {
|
|
||||||
const resp = await apiFetchClient<MeUser>("/users/@me/reroll");
|
|
||||||
addToast({ header: "Success", body: "Rerolled short ID!" });
|
|
||||||
error = null;
|
|
||||||
data.user.sid = resp.sid;
|
|
||||||
} catch (e) {
|
|
||||||
error = e as APIError;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyShortURL = async () => {
|
|
||||||
const url = `${PUBLIC_SHORT_BASE}/${data.user.sid}`;
|
|
||||||
await navigator.clipboard.writeText(url);
|
|
||||||
addToast({ body: "Copied the short link to your clipboard!", duration: 2000 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const detectTimezone = () => {
|
|
||||||
timezone = DateTime.local().zoneName;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SnapshotData {
|
|
||||||
bio: string;
|
|
||||||
display_name: string;
|
|
||||||
member_title: string;
|
|
||||||
links: string[];
|
|
||||||
names: FieldEntry[];
|
|
||||||
pronouns: Pronoun[];
|
|
||||||
fields: Field[];
|
|
||||||
flags: PrideFlag[];
|
|
||||||
list_private: boolean;
|
|
||||||
timezone: string | null;
|
|
||||||
custom_preferences: CustomPreferences;
|
|
||||||
|
|
||||||
avatar: string | null;
|
|
||||||
newName: string;
|
|
||||||
newPronouns: string;
|
|
||||||
newLink: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const snapshot: Snapshot<SnapshotData> = {
|
|
||||||
capture: () => ({
|
|
||||||
bio,
|
|
||||||
display_name,
|
|
||||||
member_title,
|
|
||||||
links,
|
|
||||||
names,
|
|
||||||
pronouns,
|
|
||||||
fields,
|
|
||||||
flags,
|
|
||||||
list_private,
|
|
||||||
timezone,
|
|
||||||
custom_preferences,
|
|
||||||
avatar,
|
|
||||||
newName,
|
|
||||||
newPronouns,
|
|
||||||
newLink,
|
|
||||||
}),
|
|
||||||
restore: (value) => {
|
|
||||||
bio = value.bio;
|
|
||||||
display_name = value.display_name;
|
|
||||||
member_title = value.member_title;
|
|
||||||
links = value.links;
|
|
||||||
names = value.names;
|
|
||||||
pronouns = value.pronouns;
|
|
||||||
fields = value.fields;
|
|
||||||
flags = value.flags;
|
|
||||||
list_private = value.list_private;
|
|
||||||
timezone = value.timezone;
|
|
||||||
custom_preferences = value.custom_preferences;
|
|
||||||
avatar = value.avatar;
|
|
||||||
newName = value.newName;
|
|
||||||
newPronouns = value.newPronouns;
|
|
||||||
newLink = value.newLink;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Edit profile - pronouns.cc</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1>
|
|
||||||
Edit profile
|
|
||||||
<ButtonGroup>
|
|
||||||
<Button color="secondary" href="/@{data.user.name}">
|
|
||||||
<Icon name="chevron-left" />
|
|
||||||
Back to your profile
|
|
||||||
</Button>
|
|
||||||
{#if modified}
|
|
||||||
<Button color="success" on:click={() => updateUser()}>Save changes</Button>
|
|
||||||
{/if}
|
|
||||||
</ButtonGroup>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<ErrorAlert {error} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<TabContent>
|
|
||||||
<TabPane tabId="avatar" tab="Names and avatar" active>
|
|
||||||
<div class="row mt-3">
|
|
||||||
<div class="col-md">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md text-center">
|
|
||||||
{#if avatar === ""}
|
|
||||||
<FallbackImage alt="Current avatar" urls={[]} width={200} />
|
|
||||||
{:else if avatar}
|
|
||||||
<img
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
src={avatar}
|
|
||||||
alt="New avatar"
|
|
||||||
class="rounded-circle img-fluid"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<FallbackImage alt="Current avatar" urls={userAvatars(data.user)} width={200} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="col-md">
|
|
||||||
<input
|
|
||||||
class="form-control"
|
|
||||||
id="avatar"
|
|
||||||
type="file"
|
|
||||||
bind:files={avatar_files}
|
|
||||||
accept="image/png, image/jpeg, image/gif, image/webp"
|
|
||||||
/>
|
|
||||||
<p class="text-muted mt-3">
|
|
||||||
<Icon name="info-circle-fill" aria-hidden /> Only PNG, JPEG, GIF, and WebP images can be
|
|
||||||
used as avatars. Avatars cannot be larger than 1 MB, and animated avatars will be made
|
|
||||||
static.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<!-- svelte-ignore a11y-invalid-attribute -->
|
|
||||||
<a href="" on:click={() => (avatar = "")}>Remove avatar</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md">
|
|
||||||
<FormGroup floating label="Username">
|
|
||||||
<Input bind:value={data.user.name} readonly />
|
|
||||||
<p class="text-muted mt-1">
|
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
|
||||||
You can change your username in
|
|
||||||
<a href="/settings" class="text-reset">your settings</a>.
|
|
||||||
</p>
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup floating label="Display name">
|
|
||||||
<Input bind:value={display_name} />
|
|
||||||
<p class="text-muted mt-1">
|
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
|
||||||
Your display name is used in page titles and as a header.
|
|
||||||
</p>
|
|
||||||
</FormGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4>Names</h4>
|
|
||||||
{#each names as _, index}
|
|
||||||
<EditableName
|
|
||||||
bind:value={names[index].value}
|
|
||||||
bind:status={names[index].status}
|
|
||||||
preferences={custom_preferences}
|
|
||||||
moveUp={() => moveName(index, true)}
|
|
||||||
moveDown={() => moveName(index, false)}
|
|
||||||
remove={() => removeName(index)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
<form class="input-group m-1" on:submit={addName}>
|
|
||||||
<input type="text" class="form-control" bind:value={newName} />
|
|
||||||
<IconButton type="submit" color="success" icon="plus" tooltip="Add name" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="bio" tab="Bio">
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="form">
|
|
||||||
<textarea class="form-control" style="height: 200px;" bind:value={bio} />
|
|
||||||
</div>
|
|
||||||
<p class="text-muted mt-1">
|
|
||||||
Using {charCount(bio)}/{MAX_DESCRIPTION_LENGTH} characters
|
|
||||||
</p>
|
|
||||||
<p class="text-muted my-2">
|
|
||||||
<MarkdownHelp />
|
|
||||||
</p>
|
|
||||||
{#if bio}
|
|
||||||
<hr />
|
|
||||||
<Card>
|
|
||||||
<CardHeader>Preview</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
{@html renderMarkdown(bio)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="pronouns" tab="Pronouns">
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="col-md">
|
|
||||||
{#each pronouns as _, index}
|
|
||||||
<EditablePronouns
|
|
||||||
bind:pronoun={pronouns[index]}
|
|
||||||
preferences={custom_preferences}
|
|
||||||
moveUp={() => movePronoun(index, true)}
|
|
||||||
moveDown={() => movePronoun(index, false)}
|
|
||||||
remove={() => removePronoun(index)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
<form class="input-group m-1" on:submit={addPronouns}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="New pronouns"
|
|
||||||
bind:value={newPronouns}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
type="submit"
|
|
||||||
color="success"
|
|
||||||
icon="plus"
|
|
||||||
tooltip="Add pronouns"
|
|
||||||
disabled={newPronouns === ""}
|
|
||||||
/>
|
|
||||||
<Button id="pronouns-help" color="secondary"><Icon name="question" /></Button>
|
|
||||||
<Popover target="pronouns-help" placement="bottom">
|
|
||||||
For common pronouns, the short form (e.g. "she/her" or "he/him") is enough; for less
|
|
||||||
common pronouns, you will have to use all five forms (e.g. "ce/cir/cir/cirs/cirself").
|
|
||||||
</Popover>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="fields" tab="Fields">
|
|
||||||
{#if data.user.fields.length === 0}
|
|
||||||
<Alert class="mt-3" color="secondary" fade={false}>
|
|
||||||
Fields are extra categories you can add separate from names and pronouns.<br />
|
|
||||||
For example, you could use them for gender terms, honorifics, or compliments.
|
|
||||||
</Alert>
|
|
||||||
{/if}
|
|
||||||
<div class="grid gap-3">
|
|
||||||
<div class="row row-cols-1 row-cols-md-2">
|
|
||||||
{#each fields as _, index}
|
|
||||||
<EditableField
|
|
||||||
bind:field={fields[index]}
|
|
||||||
preferences={custom_preferences}
|
|
||||||
deleteField={() => removeField(index)}
|
|
||||||
moveField={(up) => moveField(index, up)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button on:click={() => (fields = [...fields, { name: "New field", entries: [] }])}>
|
|
||||||
<Icon name="plus" aria-hidden /> Add new field
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="flags" tab="Flags">
|
|
||||||
<div class="mt-3">
|
|
||||||
{#each flags as _, index}
|
|
||||||
<ButtonGroup class="m-1">
|
|
||||||
<IconButton
|
|
||||||
icon="chevron-left"
|
|
||||||
color="secondary"
|
|
||||||
tooltip="Move flag to the left"
|
|
||||||
click={() => moveFlag(index, true)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon="chevron-right"
|
|
||||||
color="secondary"
|
|
||||||
tooltip="Move flag to the right"
|
|
||||||
click={() => moveFlag(index, false)}
|
|
||||||
/>
|
|
||||||
<FlagButton
|
|
||||||
flag={flags[index]}
|
|
||||||
tooltip="Remove this flag from your profile"
|
|
||||||
on:click={() => removeFlag(index)}
|
|
||||||
/>
|
|
||||||
</ButtonGroup>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md">
|
|
||||||
<Input
|
|
||||||
placeholder="Filter flags"
|
|
||||||
bind:value={flagSearch}
|
|
||||||
disabled={data.flags.length === 0}
|
|
||||||
/>
|
|
||||||
<div class="p-2">
|
|
||||||
{#each filteredFlags as flag (flag.id)}
|
|
||||||
<FlagButton
|
|
||||||
{flag}
|
|
||||||
tooltip="Add this flag to your profile"
|
|
||||||
on:click={() => addFlag(flag)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
{#if data.flags.length === 0}
|
|
||||||
You haven't uploaded any flags yet.
|
|
||||||
{:else}
|
|
||||||
There are no flags matching your search <strong>{flagSearch}</strong>.
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md">
|
|
||||||
<Alert color="secondary" fade={false}>
|
|
||||||
{#if data.flags.length === 0}
|
|
||||||
<p><strong>Why can't I see any flags?</strong></p>
|
|
||||||
<p>
|
|
||||||
There are thousands of pride flags, and it would be impossible to bundle all of them
|
|
||||||
by default. Many labels also have multiple different flags that are favoured by
|
|
||||||
different people. Because of this, there are no flags available by default--instead,
|
|
||||||
you can upload flags in your <a href="/settings/flags">settings</a>. Your main profile
|
|
||||||
and your member profiles can all have different flags.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
To upload and delete flags, go to your <a href="/settings/flags">settings</a>.
|
|
||||||
{/if}
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="links" tab="Links">
|
|
||||||
<div class="mt-3">
|
|
||||||
{#each links as _, index}
|
|
||||||
<div class="input-group m-1">
|
|
||||||
<IconButton
|
|
||||||
icon="chevron-up"
|
|
||||||
color="secondary"
|
|
||||||
tooltip="Move link up"
|
|
||||||
click={() => moveLink(index, true)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon="chevron-down"
|
|
||||||
color="secondary"
|
|
||||||
tooltip="Move link down"
|
|
||||||
click={() => moveLink(index, false)}
|
|
||||||
/>
|
|
||||||
<input type="text" class="form-control" bind:value={links[index]} />
|
|
||||||
<IconButton
|
|
||||||
color="danger"
|
|
||||||
icon="trash3"
|
|
||||||
tooltip="Remove link"
|
|
||||||
click={() => removeLink(index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<form class="input-group m-1" on:submit={addLink}>
|
|
||||||
<input type="text" class="form-control" bind:value={newLink} />
|
|
||||||
<IconButton type="submit" color="success" icon="plus" tooltip="Add link" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane tabId="other" tab="Preferences & other">
|
|
||||||
<div class="row mt-3">
|
|
||||||
<div class="col-md">
|
|
||||||
<FormGroup floating label={'"Members" header text'}>
|
|
||||||
<Input bind:value={member_title} placeholder="Members" />
|
|
||||||
<p class="text-muted mt-1">
|
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
|
||||||
This is the text used for the "Members" heading. If you leave it blank, the default text
|
|
||||||
will be used.
|
|
||||||
</p>
|
|
||||||
</FormGroup>
|
|
||||||
{#if PUBLIC_SHORT_BASE}
|
|
||||||
<hr />
|
|
||||||
<p>
|
|
||||||
Current short ID: <code>{data.user.sid}</code>
|
|
||||||
<ButtonGroup class="mb-1">
|
|
||||||
<Button color="secondary" disabled={!canRerollSid} on:click={() => rerollSid()}
|
|
||||||
>Reroll short ID</Button
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon="link-45deg"
|
|
||||||
tooltip="Copy short link"
|
|
||||||
color="secondary"
|
|
||||||
click={copyShortURL}
|
|
||||||
/>
|
|
||||||
</ButtonGroup>
|
|
||||||
<br />
|
|
||||||
<span class="text-muted">
|
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
|
||||||
This ID is used in <code>prns.cc</code> links. You can reroll one short ID every hour (shared
|
|
||||||
between your main profile and all members) by pressing the button above.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="col-md">
|
|
||||||
<div class="form-check">
|
|
||||||
<input
|
|
||||||
class="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={list_private}
|
|
||||||
id="listPrivate"
|
|
||||||
/>
|
|
||||||
<label class="form-check-label" for="listPrivate">Hide member list</label>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted mt-1">
|
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
|
||||||
This only hides your member <em>list</em>.
|
|
||||||
<strong>
|
|
||||||
Your members will still be visible to anyone at
|
|
||||||
<code class="text-nowrap">pronouns.cc/@{data.user.name}/[member-name]</code>.
|
|
||||||
</strong>
|
|
||||||
</p>
|
|
||||||
<hr />
|
|
||||||
<div class="m-1">
|
|
||||||
<p class="mt-1 my-2">
|
|
||||||
You can optionally set your timezone, which will show your current local time on your
|
|
||||||
profile.
|
|
||||||
</p>
|
|
||||||
<InputGroup>
|
|
||||||
<Button on:click={detectTimezone}>Detect timezone</Button>
|
|
||||||
<Input disabled value={timezone !== null ? timezone : "Unset"} />
|
|
||||||
<Button on:click={() => (timezone = null)}>Reset</Button>
|
|
||||||
</InputGroup>
|
|
||||||
<p class="mt-2">
|
|
||||||
{#if timezone}
|
|
||||||
This will show up on your profile like this:
|
|
||||||
<Icon name="clock" aria-hidden />
|
|
||||||
{currentTime} <span class="text-body-secondary">(UTC{displayTimezone})</span>
|
|
||||||
<br />
|
|
||||||
{/if}
|
|
||||||
<span class="text-muted">
|
|
||||||
Your timezone is never shared directly, only the difference between UTC and your
|
|
||||||
current timezone is.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>
|
|
||||||
Preferences <Button on:click={addPreference} color="success"
|
|
||||||
><Icon name="plus" aria-hidden /> Add new</Button
|
|
||||||
>
|
|
||||||
</h3>
|
|
||||||
{#each preferenceIds as id}
|
|
||||||
<CustomPreference
|
|
||||||
bind:preference={custom_preferences[id]}
|
|
||||||
remove={() => removePreference(id)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
</TabContent>
|
|
|
@ -1,23 +1,22 @@
|
||||||
import type { PrideFlag, APIError, MeUser, PronounsJson } from "$lib/api/entities";
|
import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities";
|
||||||
import { apiFetchClient } from "$lib/api/fetch";
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
import { error } from "@sveltejs/kit";
|
import { error, redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
import pronounsRaw from "$lib/pronouns.json";
|
|
||||||
const pronouns = pronounsRaw as PronounsJson;
|
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load = async () => {
|
export const load = async () => {
|
||||||
try {
|
try {
|
||||||
const user = await apiFetchClient<MeUser>(`/users/@me`);
|
const resp = await apiFetchClient<MeUser>(`/users/@me`);
|
||||||
const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
|
|
||||||
|
|
||||||
return {
|
throw redirect(303, `/@${resp.name}/edit`);
|
||||||
user,
|
|
||||||
pronouns: pronouns.autocomplete,
|
|
||||||
flags,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw error((e as APIError).code, (e as APIError).message);
|
if (
|
||||||
|
(e as APIError).code === ErrorCode.Forbidden ||
|
||||||
|
(e as APIError).code === ErrorCode.InvalidToken
|
||||||
|
) {
|
||||||
|
throw error(403, e as APIError);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
import type { PronounsJson } from "$lib/api/entities";
|
import { ErrorCode, type PronounsJson } from "$lib/api/entities";
|
||||||
|
|
||||||
import pronounsRaw from "$lib/pronouns.json";
|
import pronounsRaw from "$lib/pronouns.json";
|
||||||
const pronouns = pronounsRaw as PronounsJson;
|
const pronouns = pronounsRaw as PronounsJson;
|
||||||
|
@ -10,7 +10,7 @@ export const load = (async ({ params }) => {
|
||||||
|
|
||||||
const arr = param.split("/");
|
const arr = param.split("/");
|
||||||
if (arr.length === 0 || params.pronouns === "") {
|
if (arr.length === 0 || params.pronouns === "") {
|
||||||
throw error(404, "Pronouns not found");
|
throw error(404, { code: ErrorCode.NotFound, message: "Pronouns not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arr.length === 5) {
|
if (arr.length === 5) {
|
||||||
|
@ -46,5 +46,5 @@ export const load = (async ({ params }) => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error(404, "Pronouns not found");
|
throw error(404, { code: ErrorCode.NotFound, message: "Pronouns not found" });
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
|
|
|
@ -10,37 +10,30 @@ export const load = async ({ url }) => {
|
||||||
const reporterId = searchParams.get("reporter_id");
|
const reporterId = searchParams.get("reporter_id");
|
||||||
const isClosed = searchParams.get("closed") === "true";
|
const isClosed = searchParams.get("closed") === "true";
|
||||||
|
|
||||||
try {
|
let reports: Report[];
|
||||||
let reports: Report[];
|
if (userId) {
|
||||||
if (userId) {
|
const params = new URLSearchParams();
|
||||||
const params = new URLSearchParams();
|
if (before) params.append("before", before.toString());
|
||||||
if (before) params.append("before", before.toString());
|
|
||||||
|
|
||||||
reports = await apiFetchClient<Report[]>(
|
reports = await apiFetchClient<Report[]>(
|
||||||
`/admin/reports/by-user/${userId}?${params.toString()}`,
|
`/admin/reports/by-user/${userId}?${params.toString()}`,
|
||||||
);
|
);
|
||||||
} else if (reporterId) {
|
} else if (reporterId) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (before) params.append("before", before.toString());
|
if (before) params.append("before", before.toString());
|
||||||
|
|
||||||
reports = await apiFetchClient<Report[]>(
|
reports = await apiFetchClient<Report[]>(
|
||||||
`/admin/reports/by-reporter/${reporterId}?${params.toString()}`,
|
`/admin/reports/by-reporter/${reporterId}?${params.toString()}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (before) params.append("before", before.toString());
|
if (before) params.append("before", before.toString());
|
||||||
if (isClosed) params.append("closed", "true");
|
if (isClosed) params.append("closed", "true");
|
||||||
|
|
||||||
reports = await apiFetchClient<Report[]>(`/admin/reports?${params.toString()}`);
|
reports = await apiFetchClient<Report[]>(`/admin/reports?${params.toString()}`);
|
||||||
}
|
|
||||||
|
|
||||||
return { before, isClosed, userId, reporterId, reports } as PageLoadData;
|
|
||||||
} catch (e) {
|
|
||||||
if ((e as APIError).code === ErrorCode.Forbidden) {
|
|
||||||
throw error(400, "You're not an admin");
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { before, isClosed, userId, reporterId, reports } as PageLoadData;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PageLoadData {
|
interface PageLoadData {
|
||||||
|
|
|
@ -158,7 +158,7 @@
|
||||||
<p class="text-center">
|
<p class="text-center">
|
||||||
<FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" />
|
<FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" />
|
||||||
<br />
|
<br />
|
||||||
To change your avatar, go to <a href="/edit/profile">edit profile</a>.
|
To change your avatar, go to <a href="/@{data.user.name}/edit">edit profile</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,6 +10,6 @@ export const load = async () => {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ((e as APIError).code === ErrorCode.NotFound) return { exportData: null };
|
if ((e as APIError).code === ErrorCode.NotFound) return { exportData: null };
|
||||||
|
|
||||||
throw error((e as APIError).code, (e as APIError).message);
|
throw error(500, e as APIError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue