forked from mirrors/pronouns.cc
edit member page progress
This commit is contained in:
parent
b2b3fb37ec
commit
56c9270fdb
4 changed files with 355 additions and 2 deletions
|
@ -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>
|
||||||
|
|
171
frontend/src/routes/@[username]/[memberName]/edit/+layout.svelte
Normal file
171
frontend/src/routes/@[username]/[memberName]/edit/+layout.svelte
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
<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>
|
31
frontend/src/routes/@[username]/[memberName]/edit/+layout.ts
Normal file
31
frontend/src/routes/@[username]/[memberName]/edit/+layout.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import type { PrideFlag, MeUser, APIError, Member, PronounsJson } 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/@me/members/${params.memberName}`);
|
||||||
|
const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
|
||||||
|
|
||||||
|
if (user.name !== params.username || member.user.name !== params.username || member.name !== params.memberName) {
|
||||||
|
throw redirect(303, `/@${user.name}/${member.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
member,
|
||||||
|
pronouns: pronouns.autocomplete,
|
||||||
|
flags,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if ("code" in e) throw error(500, e as APIError);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}) satisfies LayoutLoad;
|
151
frontend/src/routes/@[username]/[memberName]/edit/+page.svelte
Normal file
151
frontend/src/routes/@[username]/[memberName]/edit/+page.svelte
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { encode } from "base64-arraybuffer";
|
||||||
|
import { FormGroup, Icon, Input } from "sveltestrap";
|
||||||
|
import { memberAvatars, type Member } from "$lib/api/entities";
|
||||||
|
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||||
|
import EditableName from "$lib/components/edit/EditableName.svelte";
|
||||||
|
import { addToast } from "$lib/toast";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
import { memberNameRegex } from "$lib/api/regex";
|
||||||
|
|
||||||
|
const MAX_AVATAR_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
const member = getContext<Writable<Member>>("member");
|
||||||
|
const currentMember = getContext<Writable<Member>>("currentMember");
|
||||||
|
|
||||||
|
// Whether the member's new name is valid.
|
||||||
|
// This is also checked in +layout.svelte, because it's easier to do it twice.
|
||||||
|
let memberNameValid = true;
|
||||||
|
$: memberNameValid = memberNameRegex.test($member.name);
|
||||||
|
|
||||||
|
// The list of avatar files uploaded.
|
||||||
|
// Only the first of these is ever used.
|
||||||
|
let avatar_files: FileList | null;
|
||||||
|
$: getAvatar(avatar_files).then((b64) => ($member.avatar = b64));
|
||||||
|
|
||||||
|
// The variable for a new name being inputted.
|
||||||
|
let newName = "";
|
||||||
|
|
||||||
|
const moveName = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == $member.names.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const temp = $member.names[index];
|
||||||
|
$member.names[index] = $member.names[newIndex];
|
||||||
|
$member.names[newIndex] = temp;
|
||||||
|
$member.names = [...$member.names];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeName = (index: number) => {
|
||||||
|
$member.names.splice(index, 1);
|
||||||
|
$member.names = [...$member.names];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addName = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
$member.names = [...$member.names, { value: newName, status: "okay" }];
|
||||||
|
newName = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvatar = async (list: FileList | null) => {
|
||||||
|
if (!list || list.length === 0) return null;
|
||||||
|
if (list[0].size > MAX_AVATAR_BYTES) {
|
||||||
|
addToast({
|
||||||
|
header: "Avatar too large",
|
||||||
|
body: `This avatar is too large, please resize it (maximum is ${prettyBytes(
|
||||||
|
MAX_AVATAR_BYTES,
|
||||||
|
)}, the file you tried to upload is ${prettyBytes(list[0].size)})`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await list[0].arrayBuffer();
|
||||||
|
const base64 = encode(buffer);
|
||||||
|
|
||||||
|
const uri = `data:${list[0].type};base64,${base64}`;
|
||||||
|
console.log(uri.slice(0, 128));
|
||||||
|
|
||||||
|
return uri;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md text-center">
|
||||||
|
{#if $member.avatar === ""}
|
||||||
|
<FallbackImage alt="Current avatar" urls={[]} width={200} />
|
||||||
|
{:else if $member.avatar}
|
||||||
|
<img
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
src={$member.avatar}
|
||||||
|
alt="New avatar"
|
||||||
|
class="rounded-circle img-fluid"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<FallbackImage alt="Current avatar" urls={memberAvatars($currentMember)} width={200} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="avatar"
|
||||||
|
type="file"
|
||||||
|
bind:files={avatar_files}
|
||||||
|
accept="image/png, image/jpeg, image/gif, image/webp"
|
||||||
|
/>
|
||||||
|
<p class="text-muted mt-3">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden /> Only PNG, JPEG, GIF, and WebP images can be used
|
||||||
|
as avatars. Avatars cannot be larger than 1 MB, and animated avatars will be made static.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<!-- svelte-ignore a11y-invalid-attribute -->
|
||||||
|
<a href="" on:click={() => ($member.avatar = "")}>Remove avatar</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<FormGroup floating label="Name">
|
||||||
|
<Input bind:value={$member.name} />
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
The member name is only used as part of the link to their profile page.
|
||||||
|
</p>
|
||||||
|
</FormGroup>
|
||||||
|
{#if !memberNameValid}
|
||||||
|
<p class="text-danger-emphasis mb-2">That member name is not valid.</p>
|
||||||
|
{/if}
|
||||||
|
<FormGroup floating label="Display name">
|
||||||
|
<Input bind:value={$member.display_name} />
|
||||||
|
</FormGroup>
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
Your display name is used in page titles and as a header.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Names</h4>
|
||||||
|
{#each $member.names as _, index}
|
||||||
|
<EditableName
|
||||||
|
bind:value={$member.names[index].value}
|
||||||
|
bind:status={$member.names[index].status}
|
||||||
|
preferences={$member.user.custom_preferences}
|
||||||
|
moveUp={() => moveName(index, true)}
|
||||||
|
moveDown={() => moveName(index, false)}
|
||||||
|
remove={() => removeName(index)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<form class="input-group m-1" on:submit={addName}>
|
||||||
|
<input type="text" class="form-control" bind:value={newName} />
|
||||||
|
<IconButton type="submit" color="success" icon="plus" tooltip="Add name" />
|
||||||
|
</form>
|
||||||
|
</div>
|
Loading…
Reference in a new issue