feat(frontend): custom preference editor
This commit is contained in:
parent
41a008799a
commit
507b9c3f4c
10 changed files with 302 additions and 8 deletions
|
@ -11,15 +11,29 @@
|
||||||
id?: string;
|
id?: string;
|
||||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
outline?: boolean;
|
outline?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
class?: string;
|
||||||
};
|
};
|
||||||
let { icon, tooltip, color = "primary", type, id, onclick, outline }: Props = $props();
|
let {
|
||||||
|
icon,
|
||||||
|
tooltip,
|
||||||
|
color = "primary",
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
onclick,
|
||||||
|
outline,
|
||||||
|
active,
|
||||||
|
class: className,
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
{id}
|
{id}
|
||||||
{type}
|
{type}
|
||||||
use:tippy={{ content: tooltip }}
|
use:tippy={{ content: tooltip }}
|
||||||
class="btn {outline ? `btn-outline-${color}` : `btn-${color}`}"
|
class="btn {outline ? `btn-outline-${color}` : `btn-${color}`} {className || ''}"
|
||||||
|
class:active
|
||||||
|
aria-pressed={active}
|
||||||
{onclick}
|
{onclick}
|
||||||
>
|
>
|
||||||
<Icon name={icon} />
|
<Icon name={icon} />
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { type CustomPreference } from "$api/models";
|
||||||
|
import IconButton from "$components/IconButton.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import PreferenceIconSelector from "./PreferenceIconSelector.svelte";
|
||||||
|
import PreferenceSizeEditor from "./PreferenceSizeEditor.svelte";
|
||||||
|
|
||||||
|
type Props = { pref: CustomPreference & { id: string | undefined }; remove: () => void };
|
||||||
|
let { pref = $bindable(), remove }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="input-group my-1">
|
||||||
|
<PreferenceIconSelector bind:icon={pref.icon} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={pref.tooltip}
|
||||||
|
class="form-control"
|
||||||
|
placeholder={$t("editor.tooltip-hint")}
|
||||||
|
/>
|
||||||
|
<PreferenceSizeEditor bind:size={pref.size} />
|
||||||
|
<IconButton
|
||||||
|
color="secondary"
|
||||||
|
icon={pref.favourite ? "star-fill" : "star"}
|
||||||
|
onclick={() => (pref.favourite = !pref.favourite)}
|
||||||
|
active={pref.favourite}
|
||||||
|
tooltip={$t("editor.custom-preference-favourite")}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
color="secondary"
|
||||||
|
icon="fonts"
|
||||||
|
onclick={() => (pref.muted = !pref.muted)}
|
||||||
|
active={pref.muted}
|
||||||
|
tooltip={$t("editor.custom-preference-muted")}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
color="danger"
|
||||||
|
icon="trash3"
|
||||||
|
tooltip={$t("editor.remove-entry")}
|
||||||
|
onclick={() => remove()}
|
||||||
|
/>
|
||||||
|
</div>
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import IconButton from "$components/IconButton.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import icons from "$lib/icons";
|
||||||
|
import {
|
||||||
|
ButtonDropdown,
|
||||||
|
DropdownToggle,
|
||||||
|
Icon,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownItem,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
import tippy from "tippy.js";
|
||||||
|
|
||||||
|
const MAX_VISIBLE_ITEMS = 20;
|
||||||
|
|
||||||
|
type Props = { icon: string };
|
||||||
|
let { icon = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
let search = $state("");
|
||||||
|
let showAll = $state(false);
|
||||||
|
let filteredIcons = $derived(
|
||||||
|
icons
|
||||||
|
.filter((icon) => icon.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
.sort()
|
||||||
|
.slice(0, showAll ? undefined : MAX_VISIBLE_ITEMS),
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalIcons = $derived(
|
||||||
|
icons.filter((icon) => icon.toLowerCase().includes(search.toLowerCase())).length,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ButtonDropdown autoClose={false}>
|
||||||
|
<span use:tippy={{ content: $t("editor.icons-change-icon") }}>
|
||||||
|
<DropdownToggle color="secondary" caret>
|
||||||
|
<Icon name={icon} />
|
||||||
|
<span class="visually-hidden">{$t("editor.icons-change-icon")}</span>
|
||||||
|
</DropdownToggle>
|
||||||
|
</span>
|
||||||
|
<DropdownMenu>
|
||||||
|
<p class="px-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
bind:value={search}
|
||||||
|
placeholder={$t("editor.flag-search-placeholder")}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<ul class="list-unstyled icon-list text-center">
|
||||||
|
{#each filteredIcons as selectable}
|
||||||
|
<IconButton
|
||||||
|
icon={selectable}
|
||||||
|
tooltip={selectable}
|
||||||
|
type="button"
|
||||||
|
color="secondary"
|
||||||
|
outline
|
||||||
|
class="border-0"
|
||||||
|
onclick={() => (icon = selectable)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if totalIcons > MAX_VISIBLE_ITEMS || showAll}
|
||||||
|
<DropdownItem divider />
|
||||||
|
<DropdownItem toggle onclick={() => (showAll = !showAll)}>
|
||||||
|
{#if showAll}
|
||||||
|
{$t("editor.icons-stop-showing-all")}
|
||||||
|
{:else}
|
||||||
|
{$t("editor.icons-show-all", { count: totalIcons })}
|
||||||
|
{/if}
|
||||||
|
</DropdownItem>
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu>
|
||||||
|
</ButtonDropdown>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.icon-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { PreferenceSize } from "$api/models";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import tippy from "$lib/tippy";
|
||||||
|
import {
|
||||||
|
ButtonDropdown,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownToggle,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
import Type from "svelte-bootstrap-icons/lib/Type.svelte";
|
||||||
|
|
||||||
|
type Props = { size: PreferenceSize };
|
||||||
|
let { size = $bindable() }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ButtonDropdown>
|
||||||
|
<span use:tippy={{ content: $t("editor.custom-preference-size") }}>
|
||||||
|
<DropdownToggle color="secondary" caret>
|
||||||
|
<Type /> <span class="visually-hidden">{$t("editor.custom-preference-size")}</span>
|
||||||
|
</DropdownToggle>
|
||||||
|
</span>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownItem
|
||||||
|
active={size === PreferenceSize.Large}
|
||||||
|
on:click={() => (size = PreferenceSize.Large)}
|
||||||
|
>
|
||||||
|
{$t("editor.custom-preference-size-large")}
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
active={size === PreferenceSize.Normal}
|
||||||
|
on:click={() => (size = PreferenceSize.Normal)}
|
||||||
|
>
|
||||||
|
{$t("editor.custom-preference-size-medium")}
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
active={size === PreferenceSize.Small}
|
||||||
|
on:click={() => (size = PreferenceSize.Small)}
|
||||||
|
>
|
||||||
|
{$t("editor.custom-preference-size-small")}
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</ButtonDropdown>
|
|
@ -146,7 +146,8 @@
|
||||||
"flag-upload-button": "Upload",
|
"flag-upload-button": "Upload",
|
||||||
"flag-description-placeholder": "Description",
|
"flag-description-placeholder": "Description",
|
||||||
"flag-name-placeholder": "Name",
|
"flag-name-placeholder": "Name",
|
||||||
"flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved."
|
"flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved.",
|
||||||
|
"custom-preferences-title": "Custom preferences"
|
||||||
},
|
},
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
|
@ -218,7 +219,18 @@
|
||||||
"flag-search-no-account-flags": "You haven't uploaded any flags yet.",
|
"flag-search-no-account-flags": "You haven't uploaded any flags yet.",
|
||||||
"flag-search-hint": "Can't find the flag you're looking for? Try using the search bar above.",
|
"flag-search-hint": "Can't find the flag you're looking for? Try using the search bar above.",
|
||||||
"flag-manage-your-flags": "Manage your flags",
|
"flag-manage-your-flags": "Manage your flags",
|
||||||
"links-header": "Links"
|
"links-header": "Links",
|
||||||
|
"icons-stop-showing-all": "Stop showing all results",
|
||||||
|
"icons-show-all": "Show all results ({{count}})",
|
||||||
|
"icons-change-icon": "Change icon",
|
||||||
|
"tooltip-hint": "Tooltip",
|
||||||
|
"add-custom-preference": "Add",
|
||||||
|
"custom-preference-size-large": "Large",
|
||||||
|
"custom-preference-size-medium": "Medium",
|
||||||
|
"custom-preference-size-small": "Small",
|
||||||
|
"custom-preference-size": "Text size",
|
||||||
|
"custom-preference-muted": "Show as muted text",
|
||||||
|
"custom-preference-favourite": "Treat like favourite"
|
||||||
},
|
},
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,12 @@
|
||||||
<NavLink active={isActive("/settings/profile", true)} href="/settings/profile">
|
<NavLink active={isActive("/settings/profile", true)} href="/settings/profile">
|
||||||
{$t("settings.your-profile-tab")}
|
{$t("settings.your-profile-tab")}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink active={isActive("/settings/flags")} href="/settings/flags">
|
||||||
|
{$t("settings.flag-title")}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink active={isActive("/settings/prefs")} href="/settings/prefs">
|
||||||
|
{$t("settings.custom-preferences-title")}
|
||||||
|
</NavLink>
|
||||||
<NavLink active={isActive("/settings/members", true)} href="/settings/members">
|
<NavLink active={isActive("/settings/members", true)} href="/settings/members">
|
||||||
{$t("settings.members-tab")}
|
{$t("settings.members-tab")}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
|
@ -112,7 +112,7 @@
|
||||||
|
|
||||||
<ClientPaginator center bind:currentPage {pageCount} />
|
<ClientPaginator center bind:currentPage {pageCount} />
|
||||||
|
|
||||||
<Accordion>
|
<Accordion class="mb-3">
|
||||||
{#each arr as flag (flag.id)}
|
{#each arr as flag (flag.id)}
|
||||||
<AccordionItem>
|
<AccordionItem>
|
||||||
<span slot="header">
|
<span slot="header">
|
||||||
|
|
|
@ -147,7 +147,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<h4>{$t("edit-profile.bio-tab")}</h4>
|
<h4>{$t("edit-profile.bio-tab")}</h4>
|
||||||
<form method="POST" action="?/bio" use:enhance>
|
<form method="POST" action="?/bio">
|
||||||
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
|
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
99
Foxnouns.Frontend/src/routes/settings/prefs/+page.svelte
Normal file
99
Foxnouns.Frontend/src/routes/settings/prefs/+page.svelte
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import ApiError, { type RawApiError } from "$api/error";
|
||||||
|
import { PreferenceSize, type CustomPreference } from "$api/models";
|
||||||
|
import CustomPreferenceEditor from "$components/editor/CustomPreferenceEditor.svelte";
|
||||||
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
type EditableCustomPreference = CustomPreference & {
|
||||||
|
id: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
let customPreferences = $state(
|
||||||
|
Object.keys(data.user.custom_preferences)
|
||||||
|
.sort()
|
||||||
|
.map(
|
||||||
|
(id) =>
|
||||||
|
({
|
||||||
|
...data.user.custom_preferences[id],
|
||||||
|
id,
|
||||||
|
}) as EditableCustomPreference,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let canAdd = $derived(customPreferences.length < data.meta.limits.custom_preferences);
|
||||||
|
// Used for form status
|
||||||
|
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
|
||||||
|
const remove = (idx: number) => {
|
||||||
|
customPreferences.splice(idx, 1);
|
||||||
|
customPreferences = [...customPreferences];
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
customPreferences = [
|
||||||
|
...customPreferences,
|
||||||
|
{
|
||||||
|
id: undefined,
|
||||||
|
tooltip: "",
|
||||||
|
icon: "question-lg",
|
||||||
|
size: PreferenceSize.Normal,
|
||||||
|
muted: false,
|
||||||
|
favourite: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiRequest<Record<string, CustomPreference>>(
|
||||||
|
"PATCH",
|
||||||
|
"/users/@me/custom-preferences",
|
||||||
|
{
|
||||||
|
body: customPreferences,
|
||||||
|
token: data.token,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
customPreferences = Object.keys(resp)
|
||||||
|
.sort()
|
||||||
|
.map(
|
||||||
|
(id) =>
|
||||||
|
({
|
||||||
|
...resp[id],
|
||||||
|
id,
|
||||||
|
}) as EditableCustomPreference,
|
||||||
|
);
|
||||||
|
|
||||||
|
ok = { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
log.error("error saving custom preferences:", e);
|
||||||
|
if (e instanceof ApiError) ok = { ok: false, error: e.obj };
|
||||||
|
else ok = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
{$t("settings.custom-preferences-title")}
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-primary" onclick={() => save()}>{$t("save-changes")}</button>
|
||||||
|
<button class="btn btn-success" onclick={add}>{$t("editor.add-custom-preference")}</button>
|
||||||
|
</div>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<FormStatusMarker form={ok} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#each customPreferences as _, idx}
|
||||||
|
<CustomPreferenceEditor bind:pref={customPreferences[idx]} remove={() => remove(idx)} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form></form>
|
|
@ -3,7 +3,6 @@
|
||||||
import type { ActionData, PageData } from "./$types";
|
import type { ActionData, PageData } from "./$types";
|
||||||
import BioEditor from "$components/editor/BioEditor.svelte";
|
import BioEditor from "$components/editor/BioEditor.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import { enhance } from "$app/forms";
|
|
||||||
|
|
||||||
type Props = { data: PageData; form: ActionData };
|
type Props = { data: PageData; form: ActionData };
|
||||||
let { data, form }: Props = $props();
|
let { data, form }: Props = $props();
|
||||||
|
@ -13,6 +12,6 @@
|
||||||
|
|
||||||
<h4>{$t("edit-profile.bio-tab")}</h4>
|
<h4>{$t("edit-profile.bio-tab")}</h4>
|
||||||
<FormStatusMarker {form} />
|
<FormStatusMarker {form} />
|
||||||
<form method="POST" use:enhance>
|
<form method="POST">
|
||||||
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
|
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
|
||||||
</form>
|
</form>
|
||||||
|
|
Loading…
Reference in a new issue