feat: add list/upload flag UI
This commit is contained in:
parent
a4698e179a
commit
8b03521382
10 changed files with 223 additions and 12 deletions
|
@ -19,6 +19,7 @@ import (
|
|||
|
||||
const ErrInvalidDataURI = errors.Sentinel("invalid data URI")
|
||||
const ErrInvalidContentType = errors.Sentinel("invalid avatar content type")
|
||||
const ErrFileTooLarge = errors.Sentinel("file to be converted exceeds maximum size")
|
||||
|
||||
// ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results.
|
||||
func (db *DB) ConvertAvatar(data string) (
|
||||
|
|
|
@ -59,7 +59,7 @@ const (
|
|||
)
|
||||
|
||||
func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) {
|
||||
sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("id").ToSql()
|
||||
sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("lower(name)").ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "building query")
|
||||
}
|
||||
|
@ -285,6 +285,8 @@ func (db *DB) FlagObject(ctx context.Context, flagID xid.ID, hash string) (io.Re
|
|||
return obj, nil
|
||||
}
|
||||
|
||||
const MaxFlagInputSize = 512_000
|
||||
|
||||
// ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result.
|
||||
func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) {
|
||||
defer vips.ShutdownThread()
|
||||
|
@ -300,6 +302,10 @@ func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) {
|
|||
return nil, errors.Wrap(err, "invalid base64 data")
|
||||
}
|
||||
|
||||
if len(rawData) > MaxFlagInputSize {
|
||||
return nil, ErrFileTooLarge
|
||||
}
|
||||
|
||||
image, err := vips.LoadImageFromBuffer(rawData, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "decoding image")
|
||||
|
|
|
@ -91,6 +91,8 @@ func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error {
|
|||
if err != nil {
|
||||
if err == db.ErrInvalidDataURI {
|
||||
return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"}
|
||||
} else if err == db.ErrFileTooLarge {
|
||||
return server.APIError{Code: server.ErrBadRequest, Message: "data URI exceeds 512 KB"}
|
||||
}
|
||||
return errors.Wrap(err, "converting flag")
|
||||
}
|
||||
|
|
|
@ -96,6 +96,13 @@ export interface MemberPartialUser {
|
|||
custom_preferences: CustomPreferences;
|
||||
}
|
||||
|
||||
export interface PrideFlag {
|
||||
id: string;
|
||||
hash: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
code: string;
|
||||
created: string;
|
||||
|
@ -192,6 +199,8 @@ export const memberAvatars = (member: Member | PartialMember) => {
|
|||
];
|
||||
};
|
||||
|
||||
export const flagURL = ({ hash }: PrideFlag) => `${PUBLIC_MEDIA_URL}/flags/${hash}.webp`;
|
||||
|
||||
export const defaultAvatars = [
|
||||
`${PUBLIC_BASE_URL}/default/512.webp`,
|
||||
`${PUBLIC_BASE_URL}/default/512.jpg`,
|
||||
|
|
|
@ -42,20 +42,13 @@
|
|||
|
||||
<div class="grid">
|
||||
<div class="row">
|
||||
<div class="col-md-3 m-3">
|
||||
<div class="col-md-3 p-3">
|
||||
<h1>Settings</h1>
|
||||
|
||||
<ListGroup>
|
||||
<ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings">
|
||||
Your profile
|
||||
</ListGroupItem>
|
||||
<ListGroupItem
|
||||
tag="a"
|
||||
active={$page.url.pathname === "/settings/auth"}
|
||||
href="/settings/auth"
|
||||
>
|
||||
Authentication
|
||||
</ListGroupItem>
|
||||
{#if hasHiddenMembers}
|
||||
<ListGroupItem
|
||||
tag="a"
|
||||
|
@ -65,6 +58,14 @@
|
|||
Hidden members
|
||||
</ListGroupItem>
|
||||
{/if}
|
||||
<ListGroupItem
|
||||
tag="a"
|
||||
active={$page.url.pathname === "/settings/flags"}
|
||||
href="/settings/flags">Flags</ListGroupItem
|
||||
>
|
||||
</ListGroup>
|
||||
<br />
|
||||
<ListGroup>
|
||||
{#if data.invitesEnabled}
|
||||
<ListGroupItem
|
||||
tag="a"
|
||||
|
@ -101,7 +102,7 @@
|
|||
<ListGroupItem tag="button" on:click={toggle}>Log out</ListGroupItem>
|
||||
</ListGroup>
|
||||
</div>
|
||||
<div class="col-md m-3">
|
||||
<div class="col-md p-3">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -155,8 +155,9 @@
|
|||
{/if}
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" />
|
||||
<p>
|
||||
<p class="text-center">
|
||||
<FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" />
|
||||
<br />
|
||||
To change your avatar, go to <a href="/edit/profile">edit profile</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
|
164
frontend/src/routes/settings/flags/+page.svelte
Normal file
164
frontend/src/routes/settings/flags/+page.svelte
Normal file
|
@ -0,0 +1,164 @@
|
|||
<script lang="ts">
|
||||
import type { APIError, PrideFlag } from "$lib/api/entities";
|
||||
import { Button, Icon, Input, Modal, ModalBody, ModalFooter, ModalHeader } from "sveltestrap";
|
||||
import type { PageData } from "./$types";
|
||||
import Flag from "./Flag.svelte";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { addToast } from "$lib/toast";
|
||||
import { encode } from "base64-arraybuffer";
|
||||
import unknownFlag from "./unknown_flag.png";
|
||||
import { apiFetchClient } from "$lib/api/fetch";
|
||||
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||
|
||||
const MAX_FLAG_BYTES = 500_000;
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let search = "";
|
||||
let error: APIError | null = null;
|
||||
|
||||
let filtered: PrideFlag[];
|
||||
$: filtered = search
|
||||
? data.flags.filter((flag) =>
|
||||
flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
|
||||
)
|
||||
: data.flags;
|
||||
|
||||
// NEW FLAG UPLOADING CODE
|
||||
let modalOpen = false;
|
||||
const toggleModal = () => (modalOpen = !modalOpen);
|
||||
let canUpload: boolean;
|
||||
$: canUpload = !!(newFlag && newName);
|
||||
|
||||
let newFlag: string | null;
|
||||
let flagFiles: FileList | null;
|
||||
$: getFlag(flagFiles).then((b64) => (newFlag = b64));
|
||||
|
||||
let newName = "";
|
||||
let newDescription = "";
|
||||
|
||||
const getFlag = async (list: FileList | null) => {
|
||||
if (!list || list.length === 0) return null;
|
||||
if (list[0].size > MAX_FLAG_BYTES) {
|
||||
addToast({
|
||||
header: "Flag too large",
|
||||
body: `This flag file is too large, please resize it (maximum is ${prettyBytes(
|
||||
MAX_FLAG_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}`;
|
||||
|
||||
return uri;
|
||||
};
|
||||
|
||||
const uploadFlag = async () => {
|
||||
try {
|
||||
const resp = await apiFetchClient<PrideFlag>("/users/@me/flags", "POST", {
|
||||
flag: newFlag,
|
||||
name: newName,
|
||||
description: newDescription || null,
|
||||
});
|
||||
|
||||
error = null;
|
||||
data.flags.push(resp);
|
||||
data.flags.sort((a, b) => a.name.localeCompare(b.name));
|
||||
data.flags = [...data.flags];
|
||||
|
||||
// reset flag
|
||||
newFlag = null;
|
||||
newName = "";
|
||||
newDescription = "";
|
||||
|
||||
addToast({ header: "Uploaded flag", body: "Successfully uploaded flag!" });
|
||||
toggleModal();
|
||||
} catch (e) {
|
||||
error = e as APIError;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<h1>Pride flags ({data.flags.length})</h1>
|
||||
|
||||
<p>
|
||||
You can upload pride flags to use on your profiles here. Flags you upload here will <em>not</em> automatically
|
||||
show up on your profile.
|
||||
</p>
|
||||
|
||||
<div class="input-group">
|
||||
<Input placeholder="Filter flags" bind:value={search} disabled={data.flags.length === 0} />
|
||||
<Button color="success" on:click={toggleModal}>
|
||||
<Icon name="upload" aria-hidden /> Upload flag
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
{#each filtered as flag}
|
||||
<Flag {flag} />
|
||||
{:else}
|
||||
{#if data.flags.length === 0}
|
||||
You haven't uploaded any flags yet, press the button above to do so.
|
||||
{:else}
|
||||
There are no flags matching your search <strong>{search}</strong>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={modalOpen} toggle={toggleModal}>
|
||||
<ModalHeader toggle={toggleModal}>Upload flag</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if error}
|
||||
<ErrorAlert {error} />
|
||||
{/if}
|
||||
<div class="d-flex align-items-center">
|
||||
<img src={newFlag || unknownFlag} alt="New flag" class="flag m-1" />
|
||||
<input
|
||||
class="form-control"
|
||||
id="flag-file"
|
||||
type="file"
|
||||
bind:files={flagFiles}
|
||||
accept="image/png, image/jpeg, image/gif, image/webp, image/svg+xml"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-muted mt-2">
|
||||
<Icon name="info-circle-fill" aria-hidden /> Only PNG, JPEG, GIF, and WebP images can be uploaded
|
||||
as flags. The file cannot be larger than 512 kilobytes.
|
||||
</p>
|
||||
<p>
|
||||
<label for="newName" class="form-label">Name</label>
|
||||
<Input id="newName" bind:value={newName} />
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
<Icon name="info-circle-fill" aria-hidden /> This name will be shown beside the flag.
|
||||
</p>
|
||||
<p>
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea id="description" class="form-control" bind:value={newDescription} />
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
<Icon name="info-circle-fill" aria-hidden /> This text will be used as the alt text of the flag
|
||||
image, and will also be shown on hover. Optional, but <strong>strongly recommended</strong> as
|
||||
it improves accessibility.
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button disabled={!canUpload} color="success" on:click={() => uploadFlag()}>Upload flag</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.flag {
|
||||
height: 2rem;
|
||||
max-width: 200px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
7
frontend/src/routes/settings/flags/+page.ts
Normal file
7
frontend/src/routes/settings/flags/+page.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { apiFetchClient } from "$lib/api/fetch";
|
||||
import type { PrideFlag } from "$lib/api/entities";
|
||||
|
||||
export const load = async () => {
|
||||
const data = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
|
||||
return { flags: data };
|
||||
};
|
20
frontend/src/routes/settings/flags/Flag.svelte
Normal file
20
frontend/src/routes/settings/flags/Flag.svelte
Normal file
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { flagURL, type PrideFlag } from "$lib/api/entities";
|
||||
import { Button } from "sveltestrap";
|
||||
|
||||
export let flag: PrideFlag;
|
||||
</script>
|
||||
|
||||
<Button outline class="m-1">
|
||||
<img class="flag" src={flagURL(flag)} alt={flag.description ?? flag.name} />
|
||||
{flag.name}
|
||||
</Button>
|
||||
|
||||
<style>
|
||||
.flag {
|
||||
height: 2rem;
|
||||
max-width: 200px;
|
||||
border-radius: 3px;
|
||||
margin-left: -5px;
|
||||
}
|
||||
</style>
|
BIN
frontend/src/routes/settings/flags/unknown_flag.png
Normal file
BIN
frontend/src/routes/settings/flags/unknown_flag.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
Loading…
Reference in a new issue