feat(frontend): also rework edit member page
This commit is contained in:
parent
c4ba4ef3d3
commit
80ca1cae00
|
@ -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>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue