feat: add list/upload flag UI

This commit is contained in:
Sam 2023-05-29 00:18:02 +02:00
parent a4698e179a
commit 8b03521382
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
10 changed files with 223 additions and 12 deletions

View file

@ -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) (

View file

@ -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")

View file

@ -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")
}

View file

@ -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`,

View file

@ -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>

View file

@ -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>

View 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>

View 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 };
};

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB