feat(frontend): also rework edit member page

This commit is contained in:
Sam 2023-04-02 22:33:09 +02:00
parent c4ba4ef3d3
commit 80ca1cae00
No known key found for this signature in database
GPG Key ID: B4EF20DDE721CAA1
2 changed files with 186 additions and 135 deletions

View File

@ -21,6 +21,12 @@
ModalBody, ModalBody,
ModalFooter, ModalFooter,
Popover, Popover,
TabContent,
TabPane,
Card,
CardBody,
CardHeader,
Alert,
} from "sveltestrap"; } from "sveltestrap";
import { encode } from "base64-arraybuffer"; import { encode } from "base64-arraybuffer";
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch"; import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
@ -32,6 +38,7 @@
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import { addToast, delToast } from "$lib/toast"; import { addToast, delToast } from "$lib/toast";
import { memberNameRegex } from "$lib/api/regex"; import { memberNameRegex } from "$lib/api/regex";
import renderMarkdown from "$lib/api/markdown";
const MAX_AVATAR_BYTES = 1_000_000; const MAX_AVATAR_BYTES = 1_000_000;
@ -51,6 +58,7 @@
let names: FieldEntry[] = window.structuredClone(data.member.names); let names: FieldEntry[] = window.structuredClone(data.member.names);
let pronouns: Pronoun[] = window.structuredClone(data.member.pronouns); let pronouns: Pronoun[] = window.structuredClone(data.member.pronouns);
let fields: Field[] = window.structuredClone(data.member.fields); let fields: Field[] = window.structuredClone(data.member.fields);
let unlisted: boolean = data.member.unlisted || false;
let memberNameValid = true; let memberNameValid = true;
$: memberNameValid = memberNameRegex.test(name); $: memberNameValid = memberNameRegex.test(name);
@ -64,10 +72,22 @@
let modified = false; let modified = false;
$: modified = isModified(bio, name, display_name, links, names, pronouns, fields, avatar); $: modified = isModified(
data.member,
bio,
name,
display_name,
links,
names,
pronouns,
fields,
avatar,
unlisted,
);
$: getAvatar(avatar_files).then((b64) => (avatar = b64)); $: getAvatar(avatar_files).then((b64) => (avatar = b64));
const isModified = ( const isModified = (
member: Member,
bio: string, bio: string,
name: string, name: string,
display_name: string, display_name: string,
@ -76,15 +96,17 @@
pronouns: Pronoun[], pronouns: Pronoun[],
fields: Field[], fields: Field[],
avatar: string | null, avatar: string | null,
unlisted: boolean,
) => { ) => {
if (name !== data.member.name) return true; if (name !== member.name) return true;
if (bio !== data.member.bio) return true; if (bio !== member.bio) return true;
if (display_name !== data.member.display_name) return true; if (display_name !== member.display_name) return true;
if (!linksEqual(links, data.member.links)) return true; if (!linksEqual(links, member.links)) return true;
if (!fieldsEqual(fields, data.member.fields)) return true; if (!fieldsEqual(fields, member.fields)) return true;
if (!namesEqual(names, data.member.names)) return true; if (!namesEqual(names, member.names)) return true;
if (!pronounsEqual(pronouns, data.member.pronouns)) return true; if (!pronounsEqual(pronouns, member.pronouns)) return true;
if (avatar !== null) return true; if (avatar !== null) return true;
if (unlisted !== member.unlisted) return true;
return false; return false;
}; };
@ -242,6 +264,7 @@
names, names,
pronouns, pronouns,
fields, fields,
unlisted,
}); });
addToast({ header: "Success", body: "Successfully saved changes!" }); addToast({ header: "Success", body: "Successfully saved changes!" });
@ -249,7 +272,6 @@
data.member = resp; data.member = resp;
avatar = null; avatar = null;
error = null; error = null;
modified = false;
} catch (e) { } catch (e) {
error = e as APIError; error = e as APIError;
} finally { } finally {
@ -341,75 +363,66 @@
<ErrorAlert {error} /> <ErrorAlert {error} />
{/if} {/if}
<div class="grid"> <TabContent>
<div class="row m-1"> <TabPane tabId="avatar" tab="Names and avatar" active>
<div class="col-md"> <div class="row mt-3">
<h4>Avatar</h4> <div class="col-md">
<div class="row"> <div class="row">
<div class="col-md text-center"> <div class="col-md text-center">
{#if avatar === ""} {#if avatar === ""}
<FallbackImage alt="Current avatar" urls={[]} width={200} /> <FallbackImage alt="Current avatar" urls={[]} width={200} />
{:else if avatar} {:else if avatar}
<img <img
width={200} width={200}
height={200} height={200}
src={avatar} src={avatar}
alt="New avatar" alt="New avatar"
class="rounded-circle img-fluid" 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"
/> />
{:else} <p class="text-muted mt-3">
<FallbackImage alt="Current avatar" urls={memberAvatars(data.member)} width={200} /> <Icon name="info-circle-fill" aria-hidden /> Only PNG, JPEG, GIF, and WebP images can be
{/if} used as avatars. Avatars cannot be larger than 1 MB, and animated avatars will be made
</div> static.
<div class="col-md mt-2"> </p>
<input <p>
class="form-control" <a href="" on:click={() => (avatar = "")}>Remove avatar</a>
id="avatar" </p>
type="file" </div>
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 can be used as
avatars. Avatars cannot be larger than 1 MB, and animated avatars will be made static.
</p>
<a href="" on:click={() => (avatar = "")}>Remove avatar</a>
</div> </div>
</div> </div>
</div> <div class="col-md">
<div class="col-md">
<div>
<FormGroup floating label="Name"> <FormGroup floating label="Name">
<Input bind:value={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> </FormGroup>
{#if !memberNameValid} {#if !memberNameValid}
<p class="text-danger-emphasis mb-2">That member name is not valid.</p> <p class="text-danger-emphasis mb-2">That member name is not valid.</p>
{/if} {/if}
</div>
<div>
<FormGroup floating label="Display name"> <FormGroup floating label="Display name">
<Input bind:value={display_name} /> <Input bind:value={display_name} />
</FormGroup> </FormGroup>
</div> <p class="text-muted mt-1">
<div> <Icon name="info-circle-fill" aria-hidden />
<div class="form"> Your display name is used in page titles and as a header.
<label for="bio"><strong>Bio ({bio.length}/{MAX_DESCRIPTION_LENGTH})</strong></label>
<textarea class="form-control" style="height: 200px;" id="bio" bind:value={bio} />
</div>
<p class="text-muted mt-3">
<Icon name="info-circle-fill" aria-hidden /> Your bio supports limited
<a
class="text-reset"
href="https://commonmark.org/help/"
target="_blank"
rel="noopener noreferrer">Markdown</a
>.
</p> </p>
</div> </div>
</div> </div>
</div> <div>
<div class="row m-1">
<div class="col-md">
<h4>Names</h4> <h4>Names</h4>
{#each names as _, index} {#each names as _, index}
<EditableName <EditableName
@ -425,51 +438,44 @@
<IconButton type="submit" color="success" icon="plus" tooltip="Add name" /> <IconButton type="submit" color="success" icon="plus" tooltip="Add name" />
</form> </form>
</div> </div>
<div class="col-md"> </TabPane>
<h4>Links</h4> <TabPane tabId="bio" tab="Bio">
{#each links as _, index} <div class="mt-3">
<div class="input-group m-1"> <div class="form">
<input type="text" class="form-control" bind:value={links[index]} /> <textarea class="form-control" style="height: 200px;" bind:value={bio} />
<IconButton </div>
color="danger" <p class="text-muted mt-1">
icon="trash3" Using {bio.length}/{MAX_DESCRIPTION_LENGTH} characters
tooltip="Remove link" </p>
click={() => removeLink(index)} <p class="text-muted my-2">
/> <Icon name="info-circle-fill" aria-hidden /> Your bio supports limited
</div> <a
{/each} class="text-reset"
<form class="input-group m-1" on:submit={addLink}> href="https://commonmark.org/help/"
<input type="text" class="form-control" bind:value={newLink} /> target="_blank"
<IconButton type="submit" color="success" icon="plus" tooltip="Add link" /> rel="noopener noreferrer">Markdown</a
</form> >.
</p>
<hr />
<Card>
<CardHeader>Preview</CardHeader>
<CardBody>
{@html renderMarkdown(bio)}
</CardBody>
</Card>
</div> </div>
</div> </TabPane>
<div class="row m-1"> <TabPane tabId="pronouns" tab="Pronouns">
<div class="col-md"> <div class="mt-3">
<h4>Pronouns</h4> <div class="col-md">
{#each pronouns as _, index} {#each pronouns as _, index}
<EditablePronouns <EditablePronouns
bind:pronoun={pronouns[index]} bind:pronoun={pronouns[index]}
moveUp={() => movePronoun(index, true)} moveUp={() => movePronoun(index, true)}
moveDown={() => movePronoun(index, false)} moveDown={() => movePronoun(index, false)}
remove={() => removePronoun(index)} remove={() => removePronoun(index)}
/> />
{/each} {/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 === ""}
/>
<form class="input-group m-1" on:submit={addPronouns}> <form class="input-group m-1" on:submit={addPronouns}>
<input <input
type="text" type="text"
@ -491,24 +497,68 @@
common pronouns, you will have to use all five forms (e.g. "ce/cir/cir/cirs/cirself"). common pronouns, you will have to use all five forms (e.g. "ce/cir/cir/cirs/cirself").
</Popover> </Popover>
</form> </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]}
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="links" tab="Links">
<div class="mt-3">
{#each links as _, index}
<div class="input-group m-1">
<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> </form>
</div> </div>
</div> </TabPane>
<hr /> <TabPane tabId="other" tab="Other">
<h4> <div class="row mt-3">
Fields <Button on:click={() => (fields = [...fields, { name: "New field", entries: [] }])}> <div class="col-md">
Add new field <div class="form-check">
</Button> <input class="form-check-input" type="checkbox" bind:checked={unlisted} id="unlisted" />
</h4> <label class="form-check-label" for="unlisted">Hide from member list</label>
</div> </div>
<div class="grid gap-3"> <p class="text-muted mt-1">
<div class="row row-cols-1 row-cols-md-2"> <Icon name="info-circle-fill" aria-hidden />
{#each fields as _, index} This <em>only</em> hides this member from your member list.
<EditableField <strong>
bind:field={fields[index]} This member will still be visible to anyone at
deleteField={() => removeField(index)} <code class="text-nowrap">pronouns.cc/@{data.user.name}/{data.member.name}</code>.
moveField={(up) => moveField(index, up)} </strong>
/> </p>
{/each} </div>
</div> </div>
</div> </TabPane>
</TabContent>

View File

@ -61,6 +61,7 @@
let modified = false; let modified = false;
$: modified = isModified( $: modified = isModified(
data.user,
bio, bio,
display_name, display_name,
links, links,
@ -74,6 +75,7 @@
$: getAvatar(avatar_files).then((b64) => (avatar = b64)); $: getAvatar(avatar_files).then((b64) => (avatar = b64));
const isModified = ( const isModified = (
user: MeUser,
bio: string, bio: string,
display_name: string, display_name: string,
links: string[], links: string[],
@ -84,15 +86,15 @@
member_title: string, member_title: string,
list_private: boolean, list_private: boolean,
) => { ) => {
if (bio !== (data.user.bio || "")) return true; if (bio !== (user.bio || "")) return true;
if (display_name !== (data.user.display_name || "")) return true; if (display_name !== (user.display_name || "")) return true;
if (member_title !== (data.user.member_title || "")) return true; if (member_title !== (user.member_title || "")) return true;
if (!linksEqual(links, data.user.links)) return true; if (!linksEqual(links, user.links)) return true;
if (!fieldsEqual(fields, data.user.fields)) return true; if (!fieldsEqual(fields, user.fields)) return true;
if (!namesEqual(names, data.user.names)) return true; if (!namesEqual(names, user.names)) return true;
if (!pronounsEqual(pronouns, data.user.pronouns)) return true; if (!pronounsEqual(pronouns, user.pronouns)) return true;
if (avatar !== null) return true; if (avatar !== null) return true;
if (list_private !== data.user.list_private) return true; if (list_private !== user.list_private) return true;
return false; return false;
}; };
@ -262,7 +264,6 @@
avatar = null; avatar = null;
error = null; error = null;
modified = false;
} catch (e) { } catch (e) {
error = e as APIError; error = e as APIError;
} finally { } finally {