feat(frontend): user profile flag editor
This commit is contained in:
parent
d9d48c3cbf
commit
2a0df335bc
12 changed files with 270 additions and 13 deletions
|
@ -29,6 +29,7 @@
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier-plugin-svelte": "^3.2.6",
|
||||||
"sass": "^1.81.0",
|
"sass": "^1.81.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-bootstrap-icons": "^3.1.1",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"sveltekit-i18n": "^2.4.2",
|
"sveltekit-i18n": "^2.4.2",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
|
|
|
@ -93,6 +93,9 @@ importers:
|
||||||
svelte:
|
svelte:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.2.2
|
version: 5.2.2
|
||||||
|
svelte-bootstrap-icons:
|
||||||
|
specifier: ^3.1.1
|
||||||
|
version: 3.1.1
|
||||||
svelte-check:
|
svelte-check:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3)
|
version: 4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3)
|
||||||
|
@ -1321,6 +1324,9 @@ packages:
|
||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
svelte-bootstrap-icons@3.1.1:
|
||||||
|
resolution: {integrity: sha512-ghJlt6TX3IX35M7wSvGyrmVgXeT5GMRF+7+q6L4OUT2RJWF09mQIvZTZ04Ii3FBfg10KdzFdvVuoB8M0cVHfzw==}
|
||||||
|
|
||||||
svelte-check@4.0.9:
|
svelte-check@4.0.9:
|
||||||
resolution: {integrity: sha512-SVNCz2L+9ZELGli7G0n3B3QE5kdf0u27RtKr2ZivWQhcWIXatZxwM4VrQ6AiA2k9zKp2mk5AxkEhdjbpjv7rEw==}
|
resolution: {integrity: sha512-SVNCz2L+9ZELGli7G0n3B3QE5kdf0u27RtKr2ZivWQhcWIXatZxwM4VrQ6AiA2k9zKp2mk5AxkEhdjbpjv7rEw==}
|
||||||
engines: {node: '>= 18.0.0'}
|
engines: {node: '>= 18.0.0'}
|
||||||
|
@ -2564,6 +2570,8 @@ snapshots:
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
svelte-bootstrap-icons@3.1.1: {}
|
||||||
|
|
||||||
svelte-check@4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3):
|
svelte-check@4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
|
|
|
@ -10,11 +10,18 @@
|
||||||
type?: "submit" | "reset" | "button";
|
type?: "submit" | "reset" | "button";
|
||||||
id?: string;
|
id?: string;
|
||||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
outline?: boolean;
|
||||||
};
|
};
|
||||||
let { icon, tooltip, color = "primary", type, id, onclick }: Props = $props();
|
let { icon, tooltip, color = "primary", type, id, onclick, outline }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button {id} {type} use:tippy={{ content: tooltip }} class="btn btn-{color}" {onclick}>
|
<button
|
||||||
|
{id}
|
||||||
|
{type}
|
||||||
|
use:tippy={{ content: tooltip }}
|
||||||
|
class="btn {outline ? `btn-outline-${color}` : `btn-${color}`}"
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
<Icon name={icon} />
|
<Icon name={icon} />
|
||||||
<span class="visually-hidden">{tooltip}</span>
|
<span class="visually-hidden">{tooltip}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import type { MouseEventHandler } from "svelte/elements";
|
||||||
|
import EditorFlagImage from "./EditorFlagImage.svelte";
|
||||||
|
import tippy from "$lib/tippy";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
flag: PrideFlag;
|
||||||
|
tooltip?: string;
|
||||||
|
class?: string;
|
||||||
|
onclick: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
padding?: boolean;
|
||||||
|
};
|
||||||
|
let { flag, tooltip, class: className, onclick, padding }: Props = $props();
|
||||||
|
|
||||||
|
let tip = $derived(tooltip ? tippy : () => {});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary {className || ''}"
|
||||||
|
class:padding
|
||||||
|
{onclick}
|
||||||
|
use:tip={{ content: tooltip }}
|
||||||
|
>
|
||||||
|
<EditorFlagImage {flag} />
|
||||||
|
{flag.name}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.padding {
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -24,11 +24,16 @@
|
||||||
<img class="flag" src={flag.image_url ?? DEFAULT_FLAG} alt={flag.description ?? flag.name} />
|
<img class="flag" src={flag.image_url ?? DEFAULT_FLAG} alt={flag.description ?? flag.name} />
|
||||||
</span>
|
</span>
|
||||||
<div class="w-lg-50">
|
<div class="w-lg-50">
|
||||||
<input class="mb-2 form-control" placeholder="Name" bind:value={name} autocomplete="off" />
|
<input
|
||||||
|
class="mb-2 form-control"
|
||||||
|
placeholder={$t("settings.flag-name-placeholder")}
|
||||||
|
bind:value={name}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
class="mb-2 form-control"
|
class="mb-2 form-control"
|
||||||
style="height: 5rem;"
|
style="height: 5rem;"
|
||||||
placeholder="Description"
|
placeholder={$t("settings.flag-description-placeholder")}
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte";
|
||||||
|
import Search from "svelte-bootstrap-icons/lib/Search.svelte";
|
||||||
|
import FlagButton from "./FlagButton.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
|
||||||
|
type Props = { flags: PrideFlag[]; select(flag: PrideFlag): void };
|
||||||
|
let { flags, select }: Props = $props();
|
||||||
|
|
||||||
|
let query = $state("");
|
||||||
|
let filteredFlags = $derived(search(query));
|
||||||
|
|
||||||
|
function search(q: string) {
|
||||||
|
if (!q) return flags.slice(0, 20);
|
||||||
|
return flags.filter((f) => f.name.toLowerCase().indexOf(q.toLowerCase()) !== -1).slice(0, 20);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input class="form-control" placeholder={$t("editor.flag-search-placeholder")} bind:value={query} />
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
{#each filteredFlags as flag (flag.id)}
|
||||||
|
<FlagButton {flag} onclick={() => select(flag)} padding />
|
||||||
|
{:else}
|
||||||
|
<div class="text-secondary text-center">
|
||||||
|
<p>
|
||||||
|
<Search class="no-flags-icon" height={64} width={64} aria-hidden />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{#if query}
|
||||||
|
{$t("editor.flag-search-no-flags")}
|
||||||
|
{:else}
|
||||||
|
{$t("editor.flag-search-no-account-flags")}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if flags.length > 0}
|
||||||
|
<p class="text-secondary mt-2">
|
||||||
|
<InfoCircleFill aria-hidden />
|
||||||
|
{$t("editor.flag-search-hint")}
|
||||||
|
<a href="/settings/flags">{$t("editor.flag-manage-your-flags")}</a>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p><a href="/settings/flags">{$t("editor.flag-manage-your-flags")}</a></p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
|
@ -0,0 +1,95 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import FlagSearch from "$components/editor/FlagSearch.svelte";
|
||||||
|
import IconButton from "$components/IconButton.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import FlagButton from "./FlagButton.svelte";
|
||||||
|
import FormStatusMarker from "./FormStatusMarker.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
profileFlags: PrideFlag[];
|
||||||
|
allFlags: PrideFlag[];
|
||||||
|
save(flags: string[]): Promise<void>;
|
||||||
|
form: { ok: boolean; error: RawApiError | null } | null;
|
||||||
|
};
|
||||||
|
let { profileFlags, allFlags, save, form }: Props = $props();
|
||||||
|
|
||||||
|
let flags = $state(profileFlags);
|
||||||
|
|
||||||
|
const select = (flag: PrideFlag) => {
|
||||||
|
flags = [...flags, flag];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFlag = (flag: PrideFlag) => {
|
||||||
|
const idx = flags.indexOf(flag);
|
||||||
|
if (idx === -1) return;
|
||||||
|
flags.splice(idx, 1);
|
||||||
|
flags = [...flags];
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveFlag = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == flags.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
const temp = flags[index];
|
||||||
|
flags[index] = flags[newIndex];
|
||||||
|
flags[newIndex] = temp;
|
||||||
|
flags = [...flags];
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveChanges = () => save(flags.map((f) => f.id));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>
|
||||||
|
{$t("settings.flag-title")}
|
||||||
|
<button type="button" class="btn btn-primary" onclick={() => saveChanges()}>
|
||||||
|
{$t("save-changes")}
|
||||||
|
</button>
|
||||||
|
</h4>
|
||||||
|
<FormStatusMarker {form} />
|
||||||
|
{#each flags as flag, i}
|
||||||
|
<div class="d-block">
|
||||||
|
<div class="btn-group flag-group">
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-up"
|
||||||
|
tooltip={$t("editor.move-flag-up")}
|
||||||
|
color="secondary"
|
||||||
|
outline
|
||||||
|
onclick={() => moveFlag(i, true)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-down"
|
||||||
|
tooltip={$t("editor.move-flag-down")}
|
||||||
|
color="secondary"
|
||||||
|
outline
|
||||||
|
onclick={() => moveFlag(i, false)}
|
||||||
|
/>
|
||||||
|
<FlagButton
|
||||||
|
{flag}
|
||||||
|
onclick={() => removeFlag(flag)}
|
||||||
|
tooltip={$t("editor.remove-this-flag")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-secondary">
|
||||||
|
{$t("editor.no-flags-hint")}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>{$t("editor.add-flags-header")}</h4>
|
||||||
|
<FlagSearch flags={allFlags} {select} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flag-group {
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -128,7 +128,10 @@
|
||||||
"flag-current-flags-title": "Current flags ({{count}}/{{max}})",
|
"flag-current-flags-title": "Current flags ({{count}}/{{max}})",
|
||||||
"flag-title": "Flags",
|
"flag-title": "Flags",
|
||||||
"flag-upload-title": "Upload a new flag",
|
"flag-upload-title": "Upload a new flag",
|
||||||
"flag-upload-button": "Upload"
|
"flag-upload-button": "Upload",
|
||||||
|
"flag-description-placeholder": "Description",
|
||||||
|
"flag-name-placeholder": "Name",
|
||||||
|
"flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved."
|
||||||
},
|
},
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
|
@ -188,7 +191,18 @@
|
||||||
"remove-field": "Remove field",
|
"remove-field": "Remove field",
|
||||||
"field-name": "Field name",
|
"field-name": "Field name",
|
||||||
"add-field": "Add field",
|
"add-field": "Add field",
|
||||||
"new-entry": "New entry"
|
"new-entry": "New entry",
|
||||||
|
"add-this-flag": "Add this flag",
|
||||||
|
"add-flags-header": "Add flags",
|
||||||
|
"move-flag-up": "Move flag up",
|
||||||
|
"move-flag-down": "Move flag down",
|
||||||
|
"remove-this-flag": "Remove this flag",
|
||||||
|
"no-flags-hint": "This profile doesn't have any flags yet! Add some with the search box.",
|
||||||
|
"flag-search-placeholder": "Type to start searching",
|
||||||
|
"flag-search-no-flags": "No flags matched your search query.",
|
||||||
|
"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-manage-your-flags": "Manage your flags"
|
||||||
},
|
},
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,10 +78,7 @@
|
||||||
<NoscriptWarning />
|
<NoscriptWarning />
|
||||||
|
|
||||||
<form method="POST" action="?/upload" enctype="multipart/form-data">
|
<form method="POST" action="?/upload" enctype="multipart/form-data">
|
||||||
<FormStatusMarker
|
<FormStatusMarker {form} successMessage={$t("settings.flag-upload-success")} />
|
||||||
{form}
|
|
||||||
successMessage="Successfully uploaded your flag! It may take a few seconds before it's saved."
|
|
||||||
/>
|
|
||||||
<h4>{$t("settings.flag-upload-title")}</h4>
|
<h4>{$t("settings.flag-upload-title")}</h4>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
@ -90,8 +87,19 @@
|
||||||
class="mb-2 form-control"
|
class="mb-2 form-control"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<input class="mb-2 form-control" name="name" placeholder="Name" autocomplete="off" required />
|
<input
|
||||||
<input class="mb-2 form-control" name="desc" placeholder="Description" autocomplete="off" />
|
class="mb-2 form-control"
|
||||||
|
name="name"
|
||||||
|
placeholder={$t("settings.flag-name-placeholder")}
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="mb-2 form-control"
|
||||||
|
name="desc"
|
||||||
|
placeholder={$t("settings.flag-description-placeholder")}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
<button type="submit" class="btn btn-primary">{$t("settings.flag-upload-button")}</button>
|
<button type="submit" class="btn btn-primary">{$t("settings.flag-upload-button")}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import ApiError from "$api/error";
|
import ApiError from "$api/error";
|
||||||
import log from "$lib/log";
|
import log from "$lib/log";
|
||||||
import { Icon, InputGroup } from "@sveltestrap/sveltestrap";
|
import { Icon, InputGroup } from "@sveltestrap/sveltestrap";
|
||||||
|
import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import AvatarEditor from "$components/editor/AvatarEditor.svelte";
|
import AvatarEditor from "$components/editor/AvatarEditor.svelte";
|
||||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||||
|
@ -133,7 +134,7 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted mt-1">
|
<p class="text-muted mt-1">
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
<InfoCircleFill aria-hidden />
|
||||||
{$t("edit-profile.unlisted-note")}
|
{$t("edit-profile.unlisted-note")}
|
||||||
<code>
|
<code>
|
||||||
{PUBLIC_BASE_URL.substring("https://".length)}/@{data.member.user.username}/{data.member
|
{PUBLIC_BASE_URL.substring("https://".length)}/@{data.member.user.username}/{data.member
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import type { PrideFlag } from "$api/models/user";
|
||||||
|
|
||||||
|
export const load = async ({ fetch, cookies }) => {
|
||||||
|
const flags = await apiRequest<PrideFlag[]>("GET", "/users/@me/flags", { fetch, cookies });
|
||||||
|
return { flags };
|
||||||
|
};
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { fastRequest } from "$api";
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import ProfileFlagsEditor from "$components/editor/ProfileFlagsEditor.svelte";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let form: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
|
||||||
|
const save = async (flags: string[]) => {
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", "/users/@me", {
|
||||||
|
body: { flags },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
form = { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not update profile flags:", e);
|
||||||
|
if (e instanceof ApiError) form = { ok: false, error: e.obj };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProfileFlagsEditor profileFlags={data.user.flags} allFlags={data.flags} {save} {form} />
|
Loading…
Reference in a new issue