edit member page progress

This commit is contained in:
sam 2023-08-12 17:01:01 +02:00
parent b2b3fb37ec
commit 56c9270fdb
No known key found for this signature in database
GPG Key ID: B4EF20DDE721CAA1
4 changed files with 355 additions and 2 deletions

View File

@ -65,7 +65,7 @@
<Alert color="secondary" fade={false}>
You are currently viewing the <strong>public</strong> profile of {data.display_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>
{/if}
<div class="m-3">
@ -93,7 +93,7 @@
<p>
<em>
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
> <span class="text-muted">(only you can see this)</span>
</p>

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

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

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>