feat: add list/upload flag UI
This commit is contained in:
parent
a4698e179a
commit
8b03521382
|
@ -19,6 +19,7 @@ import (
|
||||||
|
|
||||||
const ErrInvalidDataURI = errors.Sentinel("invalid data URI")
|
const ErrInvalidDataURI = errors.Sentinel("invalid data URI")
|
||||||
const ErrInvalidContentType = errors.Sentinel("invalid avatar content type")
|
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.
|
// ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results.
|
||||||
func (db *DB) ConvertAvatar(data string) (
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "building query")
|
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
|
return obj, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MaxFlagInputSize = 512_000
|
||||||
|
|
||||||
// ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result.
|
// 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) {
|
func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) {
|
||||||
defer vips.ShutdownThread()
|
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")
|
return nil, errors.Wrap(err, "invalid base64 data")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(rawData) > MaxFlagInputSize {
|
||||||
|
return nil, ErrFileTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
image, err := vips.LoadImageFromBuffer(rawData, nil)
|
image, err := vips.LoadImageFromBuffer(rawData, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "decoding image")
|
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 != nil {
|
||||||
if err == db.ErrInvalidDataURI {
|
if err == db.ErrInvalidDataURI {
|
||||||
return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"}
|
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")
|
return errors.Wrap(err, "converting flag")
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,6 +96,13 @@ export interface MemberPartialUser {
|
||||||
custom_preferences: CustomPreferences;
|
custom_preferences: CustomPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PrideFlag {
|
||||||
|
id: string;
|
||||||
|
hash: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Invite {
|
export interface Invite {
|
||||||
code: string;
|
code: string;
|
||||||
created: 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 = [
|
export const defaultAvatars = [
|
||||||
`${PUBLIC_BASE_URL}/default/512.webp`,
|
`${PUBLIC_BASE_URL}/default/512.webp`,
|
||||||
`${PUBLIC_BASE_URL}/default/512.jpg`,
|
`${PUBLIC_BASE_URL}/default/512.jpg`,
|
||||||
|
|
|
@ -42,20 +42,13 @@
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-3 m-3">
|
<div class="col-md-3 p-3">
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
|
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings">
|
<ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings">
|
||||||
Your profile
|
Your profile
|
||||||
</ListGroupItem>
|
</ListGroupItem>
|
||||||
<ListGroupItem
|
|
||||||
tag="a"
|
|
||||||
active={$page.url.pathname === "/settings/auth"}
|
|
||||||
href="/settings/auth"
|
|
||||||
>
|
|
||||||
Authentication
|
|
||||||
</ListGroupItem>
|
|
||||||
{#if hasHiddenMembers}
|
{#if hasHiddenMembers}
|
||||||
<ListGroupItem
|
<ListGroupItem
|
||||||
tag="a"
|
tag="a"
|
||||||
|
@ -65,6 +58,14 @@
|
||||||
Hidden members
|
Hidden members
|
||||||
</ListGroupItem>
|
</ListGroupItem>
|
||||||
{/if}
|
{/if}
|
||||||
|
<ListGroupItem
|
||||||
|
tag="a"
|
||||||
|
active={$page.url.pathname === "/settings/flags"}
|
||||||
|
href="/settings/flags">Flags</ListGroupItem
|
||||||
|
>
|
||||||
|
</ListGroup>
|
||||||
|
<br />
|
||||||
|
<ListGroup>
|
||||||
{#if data.invitesEnabled}
|
{#if data.invitesEnabled}
|
||||||
<ListGroupItem
|
<ListGroupItem
|
||||||
tag="a"
|
tag="a"
|
||||||
|
@ -101,7 +102,7 @@
|
||||||
<ListGroupItem tag="button" on:click={toggle}>Log out</ListGroupItem>
|
<ListGroupItem tag="button" on:click={toggle}>Log out</ListGroupItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md m-3">
|
<div class="col-md p-3">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -155,8 +155,9 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" />
|
<p class="text-center">
|
||||||
<p>
|
<FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" />
|
||||||
|
<br />
|
||||||
To change your avatar, go to <a href="/edit/profile">edit profile</a>.
|
To change your avatar, go to <a href="/edit/profile">edit profile</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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>
|
|
@ -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 };
|
||||||
|
};
|
|
@ -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 |
Loading…
Reference in New Issue