feat: flag management
This commit is contained in:
parent
8bd4449804
commit
d9d48c3cbf
24 changed files with 615 additions and 235 deletions
|
@ -46,13 +46,22 @@ public class FlagsController(
|
|||
|
||||
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image));
|
||||
|
||||
Snowflake id = snowflakeGenerator.GenerateSnowflake();
|
||||
var flag = new PrideFlag
|
||||
{
|
||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||
UserId = CurrentUser!.Id,
|
||||
Name = req.Name,
|
||||
Description = req.Description,
|
||||
};
|
||||
|
||||
db.Add(flag);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
queue.QueueInvocableWithPayload<CreateFlagInvocable, CreateFlagPayload>(
|
||||
new CreateFlagPayload(id, CurrentUser!.Id, req.Name, req.Image, req.Description)
|
||||
new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Name, req.Image, req.Description)
|
||||
);
|
||||
|
||||
return Accepted(new CreateFlagResponse(id, req.Name, req.Description));
|
||||
return Accepted(userRenderer.RenderPrideFlag(flag));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Foxnouns.Backend.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(DatabaseContext))]
|
||||
[Migration("20241209134148_NullableFlagHash")]
|
||||
public partial class NullableFlagHash : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "hash",
|
||||
table: "pride_flags",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text"
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "hash",
|
||||
table: "pride_flags",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -282,7 +282,6 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("hash");
|
||||
|
||||
|
@ -546,7 +545,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
|
||||
{
|
||||
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
|
||||
.WithMany()
|
||||
.WithMany("DataExports")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
|
@ -645,6 +644,8 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
{
|
||||
b.Navigation("AuthMethods");
|
||||
|
||||
b.Navigation("DataExports");
|
||||
|
||||
b.Navigation("Flags");
|
||||
|
||||
b.Navigation("Members");
|
||||
|
|
|
@ -3,7 +3,9 @@ namespace Foxnouns.Backend.Database.Models;
|
|||
public class PrideFlag : BaseModel
|
||||
{
|
||||
public required Snowflake UserId { get; init; }
|
||||
public required string Hash { get; init; }
|
||||
|
||||
// A null hash means the flag hasn't been processed yet.
|
||||
public string? Hash { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ using Foxnouns.Backend.Utils;
|
|||
|
||||
namespace Foxnouns.Backend.Dto;
|
||||
|
||||
public record PrideFlagResponse(Snowflake Id, string ImageUrl, string Name, string? Description);
|
||||
public record PrideFlagResponse(Snowflake Id, string? ImageUrl, string Name, string? Description);
|
||||
|
||||
public record CreateFlagRequest(string Name, string Image, string? Description);
|
||||
|
||||
|
|
|
@ -108,6 +108,12 @@ public class CreateDataExportInvocable(
|
|||
|
||||
private async Task WritePrideFlag(ZipArchive zip, PrideFlag flag)
|
||||
{
|
||||
if (flag.Hash == null)
|
||||
{
|
||||
_logger.Debug("Flag {FlagId} has a null hash, ignoring it", flag.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Debug("Writing flag {FlagId}", flag.Id);
|
||||
|
||||
var flagData = $"""
|
||||
|
|
|
@ -3,6 +3,7 @@ using Foxnouns.Backend.Database;
|
|||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
using Foxnouns.Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Foxnouns.Backend.Jobs;
|
||||
|
||||
|
@ -26,6 +27,18 @@ public class CreateFlagInvocable(
|
|||
|
||||
try
|
||||
{
|
||||
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||
f.Id == Payload.Id && f.UserId == Payload.UserId
|
||||
);
|
||||
if (flag == null)
|
||||
{
|
||||
_logger.Warning(
|
||||
"Got a flag create job for {FlagId} but it doesn't exist, aborting",
|
||||
Payload.Id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
(string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
||||
Payload.ImageData,
|
||||
256,
|
||||
|
@ -33,16 +46,8 @@ public class CreateFlagInvocable(
|
|||
);
|
||||
await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp");
|
||||
|
||||
var flag = new PrideFlag
|
||||
{
|
||||
Id = Payload.Id,
|
||||
UserId = Payload.UserId,
|
||||
Hash = hash,
|
||||
Name = Payload.Name,
|
||||
Description = Payload.Description,
|
||||
};
|
||||
db.Add(flag);
|
||||
|
||||
flag.Hash = hash;
|
||||
db.Update(flag);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
_logger.Information("Uploaded flag {FlagId} with hash {Hash}", flag.Id, flag.Hash);
|
||||
|
|
|
@ -83,7 +83,8 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
|||
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
||||
: null;
|
||||
|
||||
private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
|
||||
private string? ImageUrlFor(PrideFlag flag) =>
|
||||
flag.Hash != null ? $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp" : null;
|
||||
|
||||
private PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
|
||||
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);
|
||||
|
|
|
@ -131,7 +131,8 @@ public class UserRendererService(
|
|||
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
||||
: null;
|
||||
|
||||
public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
|
||||
public string? ImageUrlFor(PrideFlag flag) =>
|
||||
flag.Hash != null ? $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp" : null;
|
||||
|
||||
public PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
|
||||
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);
|
||||
|
|
|
@ -17,4 +17,5 @@ export type Limits = {
|
|||
bio_length: number;
|
||||
custom_preferences: number;
|
||||
max_auth_methods: number;
|
||||
max_flags: number;
|
||||
};
|
||||
|
|
|
@ -66,7 +66,7 @@ export type Field = {
|
|||
|
||||
export type PrideFlag = {
|
||||
id: string;
|
||||
image_url: string;
|
||||
image_url: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
|
37
Foxnouns.Frontend/src/lib/components/ClientPaginator.svelte
Normal file
37
Foxnouns.Frontend/src/lib/components/ClientPaginator.svelte
Normal file
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { Pagination, PaginationItem, PaginationLink } from "@sveltestrap/sveltestrap";
|
||||
|
||||
type Props = {
|
||||
currentPage: number;
|
||||
pageCount: number;
|
||||
center?: boolean;
|
||||
};
|
||||
let { currentPage = $bindable(), pageCount, center }: Props = $props();
|
||||
|
||||
let prevPage = $derived(currentPage > 0 ? currentPage - 1 : 0);
|
||||
let nextPage = $derived(currentPage < pageCount - 1 ? currentPage + 1 : pageCount - 1);
|
||||
</script>
|
||||
|
||||
{#if pageCount > 1}
|
||||
<div>
|
||||
<Pagination listClassName={center ? "justify-content-center" : undefined}>
|
||||
<PaginationItem>
|
||||
<PaginationLink first onclick={() => (currentPage = 0)} />
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink previous onclick={() => (currentPage = prevPage)} />
|
||||
</PaginationItem>
|
||||
{#each new Array(pageCount) as _, page}
|
||||
<PaginationItem active={page === currentPage}>
|
||||
<PaginationLink onclick={() => (currentPage = page)}>{page + 1}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/each}
|
||||
<PaginationItem>
|
||||
<PaginationLink next onclick={() => (currentPage = nextPage)} />
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink last onclick={() => (currentPage = pageCount - 1)} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
</div>
|
||||
{/if}
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { PrideFlag } from "$api/models/user";
|
||||
import { DEFAULT_FLAG } from "$lib";
|
||||
|
||||
type Props = { flag: PrideFlag };
|
||||
let { flag }: Props = $props();
|
||||
</script>
|
||||
|
||||
<img class="flag" src={flag.image_url ?? DEFAULT_FLAG} alt={flag.description ?? flag.name} />
|
||||
|
||||
<style>
|
||||
.flag {
|
||||
height: 1.5rem;
|
||||
max-width: 200px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
import type { PrideFlag } from "$api/models";
|
||||
import { DEFAULT_FLAG } from "$lib";
|
||||
import { t } from "$lib/i18n";
|
||||
|
||||
type UpdateParams = { name: string; description: string | null };
|
||||
|
||||
type Props = {
|
||||
flag: PrideFlag;
|
||||
update(id: string, params: UpdateParams): Promise<void>;
|
||||
deleteFlag(id: string): Promise<void>;
|
||||
};
|
||||
let { flag, update, deleteFlag }: Props = $props();
|
||||
|
||||
let name = $state(flag.name);
|
||||
let description = $state(flag.description);
|
||||
|
||||
const saveChanges = () =>
|
||||
update(flag.id, { name, description: description ? description : null });
|
||||
</script>
|
||||
|
||||
<div class="d-flex">
|
||||
<span class="me-3">
|
||||
<img class="flag" src={flag.image_url ?? DEFAULT_FLAG} alt={flag.description ?? flag.name} />
|
||||
</span>
|
||||
<div class="w-lg-50">
|
||||
<input class="mb-2 form-control" placeholder="Name" bind:value={name} autocomplete="off" />
|
||||
<textarea
|
||||
class="mb-2 form-control"
|
||||
style="height: 5rem;"
|
||||
placeholder="Description"
|
||||
bind:value={description}
|
||||
autocomplete="off"
|
||||
></textarea>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary" onclick={saveChanges}>
|
||||
{$t("save-changes")}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger" onclick={() => deleteFlag(flag.id)}>
|
||||
{$t("settings.flag-delete-button")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.flag {
|
||||
max-height: 6rem;
|
||||
max-width: 256px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { PrideFlag } from "$api/models/user";
|
||||
import { DEFAULT_FLAG } from "$lib";
|
||||
import tippy from "$lib/tippy";
|
||||
|
||||
type Props = { flag: PrideFlag };
|
||||
|
@ -10,7 +11,7 @@
|
|||
<img
|
||||
use:tippy={{ content: flag.description ?? flag.name }}
|
||||
class="flag"
|
||||
src={flag.image_url}
|
||||
src={flag.image_url ?? DEFAULT_FLAG}
|
||||
alt={flag.description ?? flag.name}
|
||||
/>
|
||||
{flag.name}
|
||||
|
|
|
@ -123,7 +123,12 @@
|
|||
"export-info": "You can request a copy of your data once every 24 hours. Exports are stored for 15 days (a little over two weeks) and then deleted.",
|
||||
"export-expires-at": "(expires {{expiresAt}})",
|
||||
"export-download": "Download export",
|
||||
"export-request-button": "Request a new export"
|
||||
"export-request-button": "Request a new export",
|
||||
"flag-delete-button": "Delete flag",
|
||||
"flag-current-flags-title": "Current flags ({{count}}/{{max}})",
|
||||
"flag-title": "Flags",
|
||||
"flag-upload-title": "Upload a new flag",
|
||||
"flag-upload-button": "Upload"
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
|
@ -184,5 +189,6 @@
|
|||
"field-name": "Field name",
|
||||
"add-field": "Add field",
|
||||
"new-entry": "New entry"
|
||||
}
|
||||
},
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ export const clearToken = (cookies: Cookies) => cookies.delete(TOKEN_COOKIE_NAME
|
|||
|
||||
// TODO: change this to something we actually clearly have the rights to use
|
||||
export const DEFAULT_AVATAR = "https://pronouns.cc/default/512.webp";
|
||||
export const DEFAULT_FLAG = "/unknown_flag.svg";
|
||||
|
||||
export const idTimestamp = (id: string) =>
|
||||
DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000);
|
||||
|
|
35
Foxnouns.Frontend/src/lib/paginate.ts
Normal file
35
Foxnouns.Frontend/src/lib/paginate.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
export type PaginatedArray<T> = {
|
||||
data: T[];
|
||||
currentPage: number;
|
||||
pageCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Paginates an array.
|
||||
* @param arr The array to paginate.
|
||||
* @param page The zero-indexed page number.
|
||||
* @param perPage How many items to display per page.
|
||||
* @returns An object containing a slice of the array, the current page number, and the page count.
|
||||
*/
|
||||
export default function paginate<T>(
|
||||
arr: T[] | null,
|
||||
page: string | number | null,
|
||||
perPage: number,
|
||||
): PaginatedArray<T> {
|
||||
if (arr && arr.length > 0) {
|
||||
let currentPage = 0;
|
||||
if (page && typeof page === "string") currentPage = parseInt(page);
|
||||
if (page && typeof page === "number") currentPage = page;
|
||||
|
||||
const pageCount = Math.ceil(arr.length / perPage);
|
||||
let data = arr.slice(currentPage * perPage, (currentPage + 1) * perPage);
|
||||
if (data.length === 0) {
|
||||
data = arr.slice(0, perPage);
|
||||
currentPage = 0;
|
||||
}
|
||||
|
||||
return { data, currentPage, pageCount };
|
||||
}
|
||||
|
||||
return { data: [], currentPage: 0, pageCount: 1 };
|
||||
}
|
|
@ -1,7 +1,8 @@
|
|||
import { apiRequest } from "$api";
|
||||
import ApiError, { ErrorCode } from "$api/error.js";
|
||||
import type { PartialMember, User, UserWithMembers } from "$api/models";
|
||||
import type { UserWithMembers } from "$api/models";
|
||||
import log from "$lib/log.js";
|
||||
import paginate from "$lib/paginate";
|
||||
import { error } from "@sveltejs/kit";
|
||||
|
||||
const MEMBERS_PER_PAGE = 20;
|
||||
|
@ -20,22 +21,11 @@ export const load = async ({ params, fetch, cookies, url }) => {
|
|||
throw e;
|
||||
}
|
||||
|
||||
// Paginate members on the server side
|
||||
let currentPage = 0;
|
||||
let pageCount = 0;
|
||||
let members: PartialMember[] = [];
|
||||
if (user.members) {
|
||||
currentPage = Number(url.searchParams.get("page") || "0");
|
||||
pageCount = Math.ceil(user.members.length / MEMBERS_PER_PAGE);
|
||||
members = user.members.slice(
|
||||
currentPage * MEMBERS_PER_PAGE,
|
||||
(currentPage + 1) * MEMBERS_PER_PAGE,
|
||||
const { data, currentPage, pageCount } = paginate(
|
||||
user.members,
|
||||
url.searchParams.get("page"),
|
||||
MEMBERS_PER_PAGE,
|
||||
);
|
||||
if (members.length === 0) {
|
||||
members = user.members.slice(0, MEMBERS_PER_PAGE);
|
||||
currentPage = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { user, members, currentPage, pageCount };
|
||||
return { user, members: data, currentPage, pageCount };
|
||||
};
|
||||
|
|
42
Foxnouns.Frontend/src/routes/settings/flags/+page.server.ts
Normal file
42
Foxnouns.Frontend/src/routes/settings/flags/+page.server.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { apiRequest, fastRequest } from "$api";
|
||||
import ApiError from "$api/error";
|
||||
import type { PrideFlag } from "$api/models/user";
|
||||
import log from "$lib/log";
|
||||
import { encode } from "base64-arraybuffer";
|
||||
|
||||
export const load = async ({ url, fetch, cookies }) => {
|
||||
const resp = await apiRequest<PrideFlag[]>("GET", "/users/@me/flags", { fetch, cookies });
|
||||
|
||||
return {
|
||||
flags: resp,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
upload: async ({ request, fetch, cookies }) => {
|
||||
const body = await request.formData();
|
||||
const name = body.get("name") as string;
|
||||
const description = body.get("desc") as string;
|
||||
const image = body.get("image") as File;
|
||||
|
||||
const buffer = await image.arrayBuffer();
|
||||
const base64 = encode(buffer);
|
||||
|
||||
try {
|
||||
await fastRequest("POST", "/users/@me/flags", {
|
||||
body: {
|
||||
name,
|
||||
description: description ? description : null,
|
||||
image: `data:${image.type};base64,${base64}`,
|
||||
},
|
||||
fetch,
|
||||
cookies,
|
||||
});
|
||||
return { ok: true, error: null };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { ok: false, error: e.obj };
|
||||
log.error("error uploading flag:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
};
|
121
Foxnouns.Frontend/src/routes/settings/flags/+page.svelte
Normal file
121
Foxnouns.Frontend/src/routes/settings/flags/+page.svelte
Normal file
|
@ -0,0 +1,121 @@
|
|||
<script lang="ts">
|
||||
import { Accordion, AccordionItem } from "@sveltestrap/sveltestrap";
|
||||
import type { ActionData, PageData } from "./$types";
|
||||
import EditorFlagImage from "$components/editor/EditorFlagImage.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import type { PrideFlag } from "$api/models";
|
||||
import paginate from "$lib/paginate";
|
||||
import ClientPaginator from "$components/ClientPaginator.svelte";
|
||||
import FlagEditor from "$components/editor/FlagEditor.svelte";
|
||||
import NoscriptWarning from "$components/editor/NoscriptWarning.svelte";
|
||||
import { fastRequest } from "$api";
|
||||
import type { RawApiError } from "$api/error";
|
||||
import ApiError from "$api/error";
|
||||
import log from "$lib/log";
|
||||
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||
|
||||
type Props = { data: PageData; form: ActionData };
|
||||
let { data, form }: Props = $props();
|
||||
|
||||
let flags = $state(data.flags);
|
||||
|
||||
let arr: PrideFlag[] = $state([]);
|
||||
let currentPage = $state(0);
|
||||
let pageCount = $state(0);
|
||||
|
||||
const FLAGS_PER_PAGE = 50;
|
||||
|
||||
$effect(() => {
|
||||
const pages = paginate(flags, currentPage, FLAGS_PER_PAGE);
|
||||
arr = pages.data;
|
||||
pageCount = pages.pageCount;
|
||||
});
|
||||
|
||||
let lastEditedFlag: string | null = $state(null);
|
||||
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||
|
||||
const update = async (
|
||||
id: string,
|
||||
{ name, description }: { name: string; description: string | null },
|
||||
) => {
|
||||
lastEditedFlag = id;
|
||||
try {
|
||||
await fastRequest("PATCH", `/users/@me/flags/${id}`, {
|
||||
body: { name, description },
|
||||
token: data.token,
|
||||
});
|
||||
ok = { ok: true, error: null };
|
||||
|
||||
const idx = flags.findIndex((f) => f.id === id);
|
||||
if (idx === -1) return;
|
||||
console.log("yippee");
|
||||
flags[idx] = { ...flags[idx], name, description };
|
||||
} catch (e) {
|
||||
log.error("Could not update flag %s:", id, e);
|
||||
if (e instanceof ApiError) ok = { ok: false, error: e.obj };
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFlag = async (id: string) => {
|
||||
lastEditedFlag = id;
|
||||
try {
|
||||
await fastRequest("DELETE", `/users/@me/flags/${id}`, { token: data.token });
|
||||
ok = { ok: true, error: null };
|
||||
|
||||
const idx = flags.findIndex((f) => f.id === id);
|
||||
if (idx === -1) return;
|
||||
flags.splice(idx, 1);
|
||||
flags = [...flags];
|
||||
} catch (e) {
|
||||
log.error("Could not remove flag %s:", id, e);
|
||||
if (e instanceof ApiError) ok = { ok: false, error: e.obj };
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<h3>{$t("settings.flag-title")}</h3>
|
||||
|
||||
<NoscriptWarning />
|
||||
|
||||
<form method="POST" action="?/upload" enctype="multipart/form-data">
|
||||
<FormStatusMarker
|
||||
{form}
|
||||
successMessage="Successfully uploaded your flag! It may take a few seconds before it's saved."
|
||||
/>
|
||||
<h4>{$t("settings.flag-upload-title")}</h4>
|
||||
<input
|
||||
type="file"
|
||||
name="image"
|
||||
accept="image/png, image/jpeg, image/gif, image/webp"
|
||||
class="mb-2 form-control"
|
||||
required
|
||||
/>
|
||||
<input class="mb-2 form-control" name="name" placeholder="Name" autocomplete="off" required />
|
||||
<input class="mb-2 form-control" name="desc" placeholder="Description" autocomplete="off" />
|
||||
<button type="submit" class="btn btn-primary">{$t("settings.flag-upload-button")}</button>
|
||||
</form>
|
||||
|
||||
<h4 class="mt-3">
|
||||
{$t("settings.flag-current-flags-title", {
|
||||
count: data.flags.length,
|
||||
max: data.meta.limits.max_flags,
|
||||
})}
|
||||
</h4>
|
||||
|
||||
<ClientPaginator center bind:currentPage {pageCount} />
|
||||
|
||||
<Accordion>
|
||||
{#each arr as flag (flag.id)}
|
||||
<AccordionItem>
|
||||
<span slot="header">
|
||||
<EditorFlagImage {flag} />
|
||||
{flag.name}
|
||||
</span>
|
||||
|
||||
{#if lastEditedFlag === flag.id}<FormStatusMarker form={ok} />{/if}
|
||||
<FlagEditor {flag} {update} {deleteFlag} />
|
||||
</AccordionItem>
|
||||
{/each}
|
||||
</Accordion>
|
||||
|
||||
<ClientPaginator center bind:currentPage {pageCount} />
|
|
@ -1,18 +1,15 @@
|
|||
import paginate from "$lib/paginate";
|
||||
|
||||
const MEMBERS_PER_PAGE = 15;
|
||||
|
||||
export const load = async ({ url, parent }) => {
|
||||
const { user } = await parent();
|
||||
|
||||
let currentPage = Number(url.searchParams.get("page") || "0");
|
||||
let pageCount = Math.ceil(user.members.length / MEMBERS_PER_PAGE);
|
||||
let members = user.members.slice(
|
||||
currentPage * MEMBERS_PER_PAGE,
|
||||
(currentPage + 1) * MEMBERS_PER_PAGE,
|
||||
const { data, currentPage, pageCount } = paginate(
|
||||
user.members,
|
||||
url.searchParams.get("page"),
|
||||
MEMBERS_PER_PAGE,
|
||||
);
|
||||
if (members.length === 0) {
|
||||
members = user.members.slice(0, MEMBERS_PER_PAGE);
|
||||
currentPage = 0;
|
||||
}
|
||||
|
||||
return { members, currentPage, pageCount };
|
||||
return { members: data, currentPage, pageCount };
|
||||
};
|
||||
|
|
9
Foxnouns.Frontend/static/unknown_flag.svg
Normal file
9
Foxnouns.Frontend/static/unknown_flag.svg
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" version="1.0" width="225" height="150" id="svg2">
|
||||
<defs id="defs5"/>
|
||||
<path d="M 0,0 L 0,50 L 225,50 L 225,0 L 0,0 z " style="fill:#bbb" id="rect1308"/>
|
||||
<path d="M 0,50 L 0,100 L 225,100 L 225,50 L 0,50 z " style="fill:#d4d4d4" id="rect1310"/>
|
||||
<path d="M 0,100 L 0,150 L 225,150 L 225,100 L 0,100 z " style="fill:#e3e3e3" id="rect1313"/>
|
||||
<path d="M 64.255859,62.075068 L 63.136719,62.075068 C 63.128904,62.047727 63.092772,61.948118 63.02832,61.77624 C 62.963866,61.604368 62.931639,61.453977 62.931641,61.325068 C 62.931639,61.078978 62.965819,60.84265 63.03418,60.616084 C 63.102537,60.389525 63.203123,60.177611 63.335938,59.980341 C 63.468748,59.783081 63.731443,59.467651 64.124023,59.034052 C 64.516598,58.600465 64.712887,58.243043 64.712891,57.961787 C 64.712887,57.414919 64.355466,57.141482 63.640625,57.141474 C 63.292967,57.141482 62.929686,57.31531 62.550781,57.662959 L 61.947266,56.532099 C 62.451171,56.137576 63.113279,55.940311 63.933594,55.940302 C 64.566403,55.940311 65.094723,56.116092 65.518555,56.467646 C 65.942378,56.819216 66.154292,57.286013 66.154297,57.868037 C 66.154292,58.266481 66.077144,58.603394 65.922852,58.878779 C 65.76855,59.154175 65.497066,59.477417 65.108398,59.848505 C 64.719723,60.219604 64.466794,60.528197 64.349609,60.774287 C 64.232419,61.020384 64.173825,61.289915 64.173828,61.58288 C 64.173825,61.645383 64.201169,61.809446 64.255859,62.075068 L 64.255859,62.075068 z M 63.757813,62.871943 C 64.023435,62.871945 64.249021,62.965695 64.43457,63.153193 C 64.620114,63.340694 64.712887,63.567257 64.712891,63.83288 C 64.712887,64.098506 64.620114,64.325068 64.43457,64.512568 C 64.249021,64.700068 64.023435,64.793818 63.757813,64.793818 C 63.492185,64.793818 63.265623,64.700068 63.078125,64.512568 C 62.890623,64.325068 62.796874,64.098506 62.796875,63.83288 C 62.796874,63.567257 62.890623,63.340694 63.078125,63.153193 C 63.265623,62.965695 63.492185,62.871945 63.757813,62.871943 L 63.757813,62.871943 z " transform="matrix(10.52848,0,0,10.52848,-561.8574,-560.5734)" style="font-size:12px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;fill:white;font-family:Trebuchet MS" id="flowRoot1875"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.4 KiB |
|
@ -33,3 +33,8 @@ Code taken entirely or almost entirely from external sources:
|
|||
taken from [PluralKit](https://github.com/PluralKit/PluralKit/blob/32a6e97342acc3b35e6f9e7b4dd169e21d888770/PluralKit.Core/Database/Functions/functions.sql)
|
||||
- `Foxnouns.Backend/Database/prune-designer-cs-files.sh`,
|
||||
taken from [Iceshrimp.NET](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/commit/7c93dcf79dda54fc1a4ea9772e3f80874e6bcefb/Iceshrimp.Backend/Core/Database/prune-designer-cs-files.sh)
|
||||
|
||||
Files under a different license:
|
||||
|
||||
- `Foxnouns.Frontend/static/unknown_flag.svg` is https://commons.wikimedia.org/wiki/File:Unknown_flag.svg,
|
||||
by 8938e on Wikimedia Commons, licensed as CC BY-SA 4.0.
|
Loading…
Reference in a new issue