merge: reworked edit pages

This commit is contained in:
sam 2023-08-14 02:15:40 +02:00
commit 9ee6f318c7
No known key found for this signature in database
GPG key ID: B4EF20DDE721CAA1
44 changed files with 1741 additions and 1756 deletions

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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);
} }
}; };

View file

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

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

View 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;

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

View file

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

View file

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

View 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 { 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>

View file

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

View file

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

View file

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

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

View 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;
}
};

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

View 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}

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

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

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

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

View file

@ -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 = "";

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

View file

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

View 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}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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);
} }
}; };