feat: flag management

This commit is contained in:
sam 2024-12-09 14:52:31 +01:00
parent 8bd4449804
commit d9d48c3cbf
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
24 changed files with 615 additions and 235 deletions

View file

@ -46,13 +46,22 @@ public class FlagsController(
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image)); 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>( 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}")] [HttpPatch("{id}")]

View file

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

View file

@ -282,7 +282,6 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnName("description"); .HasColumnName("description");
b.Property<string>("Hash") b.Property<string>("Hash")
.IsRequired()
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("hash"); .HasColumnName("hash");
@ -546,7 +545,7 @@ namespace Foxnouns.Backend.Database.Migrations
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
{ {
b.HasOne("Foxnouns.Backend.Database.Models.User", "User") b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany() .WithMany("DataExports")
.HasForeignKey("UserId") .HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .IsRequired()
@ -645,6 +644,8 @@ namespace Foxnouns.Backend.Database.Migrations
{ {
b.Navigation("AuthMethods"); b.Navigation("AuthMethods");
b.Navigation("DataExports");
b.Navigation("Flags"); b.Navigation("Flags");
b.Navigation("Members"); b.Navigation("Members");

View file

@ -3,7 +3,9 @@ namespace Foxnouns.Backend.Database.Models;
public class PrideFlag : BaseModel public class PrideFlag : BaseModel
{ {
public required Snowflake UserId { get; init; } 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 required string Name { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
} }

View file

@ -4,7 +4,7 @@ using Foxnouns.Backend.Utils;
namespace Foxnouns.Backend.Dto; 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); public record CreateFlagRequest(string Name, string Image, string? Description);

View file

@ -108,6 +108,12 @@ public class CreateDataExportInvocable(
private async Task WritePrideFlag(ZipArchive zip, PrideFlag flag) 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); _logger.Debug("Writing flag {FlagId}", flag.Id);
var flagData = $""" var flagData = $"""

View file

@ -3,6 +3,7 @@ using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Jobs; namespace Foxnouns.Backend.Jobs;
@ -26,6 +27,18 @@ public class CreateFlagInvocable(
try 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( (string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage(
Payload.ImageData, Payload.ImageData,
256, 256,
@ -33,16 +46,8 @@ public class CreateFlagInvocable(
); );
await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp"); await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp");
var flag = new PrideFlag flag.Hash = hash;
{ db.Update(flag);
Id = Payload.Id,
UserId = Payload.UserId,
Hash = hash,
Name = Payload.Name,
Description = Payload.Description,
};
db.Add(flag);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_logger.Information("Uploaded flag {FlagId} with hash {Hash}", flag.Id, flag.Hash); _logger.Information("Uploaded flag {FlagId} with hash {Hash}", flag.Id, flag.Hash);

View file

@ -83,7 +83,8 @@ public class MemberRendererService(DatabaseContext db, Config config)
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
: null; : 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) => private PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description); new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);

View file

@ -131,7 +131,8 @@ public class UserRendererService(
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
: null; : 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) => public PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description); new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);

View file

@ -17,4 +17,5 @@ export type Limits = {
bio_length: number; bio_length: number;
custom_preferences: number; custom_preferences: number;
max_auth_methods: number; max_auth_methods: number;
max_flags: number;
}; };

View file

@ -66,7 +66,7 @@ export type Field = {
export type PrideFlag = { export type PrideFlag = {
id: string; id: string;
image_url: string; image_url: string | null;
name: string; name: string;
description: string | null; description: string | null;
}; };

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { PrideFlag } from "$api/models/user"; import type { PrideFlag } from "$api/models/user";
import { DEFAULT_FLAG } from "$lib";
import tippy from "$lib/tippy"; import tippy from "$lib/tippy";
type Props = { flag: PrideFlag }; type Props = { flag: PrideFlag };
@ -10,7 +11,7 @@
<img <img
use:tippy={{ content: flag.description ?? flag.name }} use:tippy={{ content: flag.description ?? flag.name }}
class="flag" class="flag"
src={flag.image_url} src={flag.image_url ?? DEFAULT_FLAG}
alt={flag.description ?? flag.name} alt={flag.description ?? flag.name}
/> />
{flag.name} {flag.name}

View file

@ -1,188 +1,194 @@
{ {
"hello": "Hello, {{name}}!", "hello": "Hello, {{name}}!",
"nav": { "nav": {
"log-in": "Log in or sign up", "log-in": "Log in or sign up",
"settings": "Settings" "settings": "Settings"
}, },
"avatar-tooltip": "Avatar for {{name}}", "avatar-tooltip": "Avatar for {{name}}",
"profile": { "profile": {
"edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.", "edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.",
"edit-user-profile-notice": "You are currently viewing your public profile.", "edit-user-profile-notice": "You are currently viewing your public profile.",
"edit-profile-link": "Edit profile", "edit-profile-link": "Edit profile",
"names-header": "Names", "names-header": "Names",
"pronouns-header": "Pronouns", "pronouns-header": "Pronouns",
"default-members-header": "Members", "default-members-header": "Members",
"create-member-button": "Create member", "create-member-button": "Create member",
"back-to-user": "Back to {{name}}" "back-to-user": "Back to {{name}}"
}, },
"title": { "title": {
"log-in": "Log in", "log-in": "Log in",
"welcome": "Welcome", "welcome": "Welcome",
"settings": "Settings", "settings": "Settings",
"an-error-occurred": "An error occurred" "an-error-occurred": "An error occurred"
}, },
"auth": { "auth": {
"log-in-form-title": "Log in with email", "log-in-form-title": "Log in with email",
"log-in-form-email-label": "Email address", "log-in-form-email-label": "Email address",
"log-in-form-password-label": "Password", "log-in-form-password-label": "Password",
"register-with-email-button": "Register with email", "register-with-email-button": "Register with email",
"log-in-button": "Log in", "log-in-button": "Log in",
"log-in-3rd-party-header": "Log in with another service", "log-in-3rd-party-header": "Log in with another service",
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:", "log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
"log-in-with-discord": "Log in with Discord", "log-in-with-discord": "Log in with Discord",
"log-in-with-google": "Log in with Google", "log-in-with-google": "Log in with Google",
"log-in-with-tumblr": "Log in with Tumblr", "log-in-with-tumblr": "Log in with Tumblr",
"log-in-with-the-fediverse": "Log in with the Fediverse", "log-in-with-the-fediverse": "Log in with the Fediverse",
"remote-fediverse-account-label": "Your Fediverse account", "remote-fediverse-account-label": "Your Fediverse account",
"register-username-label": "Username", "register-username-label": "Username",
"register-button": "Register account", "register-button": "Register account",
"register-with-mastodon": "Register with a Fediverse account", "register-with-mastodon": "Register with a Fediverse account",
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?", "log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end", "log-in-with-fediverse-force-refresh-button": "Force a refresh on our end",
"register-with-discord": "Register with a Discord account", "register-with-discord": "Register with a Discord account",
"new-auth-method-added": "Successfully added authentication method!", "new-auth-method-added": "Successfully added authentication method!",
"successful-link-discord": "Your account has successfully been linked to the following Discord account:", "successful-link-discord": "Your account has successfully been linked to the following Discord account:",
"successful-link-google": "Your account has successfully been linked to the following Google account:", "successful-link-google": "Your account has successfully been linked to the following Google account:",
"successful-link-tumblr": "Your account has successfully been linked to the following Tumblr account:", "successful-link-tumblr": "Your account has successfully been linked to the following Tumblr account:",
"successful-link-fedi": "Your account has successfully been linked to the following fediverse account:", "successful-link-fedi": "Your account has successfully been linked to the following fediverse account:",
"successful-link-profile-hint": "You now can close this page, or go back to your profile:", "successful-link-profile-hint": "You now can close this page, or go back to your profile:",
"successful-link-profile-link": "Go to your profile", "successful-link-profile-link": "Go to your profile",
"remote-discord-account-label": "Your Discord account", "remote-discord-account-label": "Your Discord account",
"log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)", "log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)",
"register-with-email": "Register with an email address", "register-with-email": "Register with an email address",
"email-label": "Your email address", "email-label": "Your email address",
"confirm-password-label": "Confirm password", "confirm-password-label": "Confirm password",
"register-with-email-init-success": "Success! An email has been sent to your inbox, please press the link there to continue." "register-with-email-init-success": "Success! An email has been sent to your inbox, please press the link there to continue."
}, },
"error": { "error": {
"bad-request-header": "Something was wrong with your input", "bad-request-header": "Something was wrong with your input",
"generic-header": "Something went wrong", "generic-header": "Something went wrong",
"raw-header": "Raw error", "raw-header": "Raw error",
"authentication-error": "Something went wrong when logging you in.", "authentication-error": "Something went wrong when logging you in.",
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.", "bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
"forbidden": "You are not allowed to perform that action.", "forbidden": "You are not allowed to perform that action.",
"internal-server-error": "Server experienced an internal error, please try again later.", "internal-server-error": "Server experienced an internal error, please try again later.",
"authentication-required": "You need to log in first.", "authentication-required": "You need to log in first.",
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?", "missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
"generic-error": "An unknown error occurred.", "generic-error": "An unknown error occurred.",
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.", "user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
"member-not-found": "Member not found, please check your spelling and try again.", "member-not-found": "Member not found, please check your spelling and try again.",
"account-already-linked": "This account is already linked with a pronouns.cc account.", "account-already-linked": "This account is already linked with a pronouns.cc account.",
"last-auth-method": "You cannot remove your last authentication method.", "last-auth-method": "You cannot remove your last authentication method.",
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.", "validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
"validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.", "validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.",
"validation-disallowed-value-1": "The following value is not allowed here", "validation-disallowed-value-1": "The following value is not allowed here",
"validation-disallowed-value-2": "Allowed values are", "validation-disallowed-value-2": "Allowed values are",
"validation-reason": "Reason", "validation-reason": "Reason",
"validation-generic": "The value you entered is not allowed here. Reason", "validation-generic": "The value you entered is not allowed here. Reason",
"extra-info-header": "Extra error information", "extra-info-header": "Extra error information",
"noscript-title": "This page requires JavaScript", "noscript-title": "This page requires JavaScript",
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.", "noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
"noscript-short": "Requires JavaScript", "noscript-short": "Requires JavaScript",
"404-description": "The page you were trying to visit was not found. If you're sure the page should exist, check your address bar for any typos.", "404-description": "The page you were trying to visit was not found. If you're sure the page should exist, check your address bar for any typos.",
"back-to-profile-button": "Go back to your profile", "back-to-profile-button": "Go back to your profile",
"back-to-main-page-button": "Go back to the main page", "back-to-main-page-button": "Go back to the main page",
"back-to-prev-page-button": "Go back to the previous page", "back-to-prev-page-button": "Go back to the previous page",
"400-description": "Something went wrong with your request. This error should never land you on this page, so it's probably a bug.", "400-description": "Something went wrong with your request. This error should never land you on this page, so it's probably a bug.",
"500-description": "Something went wrong on the server. Please try again later.", "500-description": "Something went wrong on the server. Please try again later.",
"unknown-status-description": "Something went wrong, but we're not sure what. Please try again." "unknown-status-description": "Something went wrong, but we're not sure what. Please try again."
}, },
"settings": { "settings": {
"general-information-tab": "General information", "general-information-tab": "General information",
"your-profile-tab": "Your profile", "your-profile-tab": "Your profile",
"members-tab": "Members", "members-tab": "Members",
"authentication-tab": "Authentication", "authentication-tab": "Authentication",
"export-tab": "Export your data", "export-tab": "Export your data",
"change-username-button": "Change username", "change-username-button": "Change username",
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.", "username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}", "username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
"change-avatar-link": "Change your avatar here", "change-avatar-link": "Change your avatar here",
"new-username": "New username", "new-username": "New username",
"table-role": "Role", "table-role": "Role",
"table-custom-preferences": "Custom preferences", "table-custom-preferences": "Custom preferences",
"table-member-list-hidden": "Member list hidden?", "table-member-list-hidden": "Member list hidden?",
"table-member-count": "Member count", "table-member-count": "Member count",
"table-created-at": "Account created at", "table-created-at": "Account created at",
"table-id": "Your ID", "table-id": "Your ID",
"table-title": "Account information", "table-title": "Account information",
"force-log-out-title": "Log out everywhere", "force-log-out-title": "Log out everywhere",
"force-log-out-button": "Force log out", "force-log-out-button": "Force log out",
"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.", "force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
"log-out-title": "Log out", "log-out-title": "Log out",
"log-out-hint": "Use this button to log out on this device only.", "log-out-hint": "Use this button to log out on this device only.",
"log-out-button": "Log out", "log-out-button": "Log out",
"avatar": "Avatar", "avatar": "Avatar",
"username-update-success": "Successfully changed your username!", "username-update-success": "Successfully changed your username!",
"create-member-title": "Create a new member", "create-member-title": "Create a new member",
"create-member-name-label": "Member name", "create-member-name-label": "Member name",
"auth-remove-method": "Remove", "auth-remove-method": "Remove",
"force-log-out-warning": "Make sure you're still able to log in before using this!", "force-log-out-warning": "Make sure you're still able to log in before using this!",
"force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page.", "force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page.",
"export-request-success": "Successfully requested a new export! Please note that it may take a few minutes to complete, especially if you have a lot of members.", "export-request-success": "Successfully requested a new export! Please note that it may take a few minutes to complete, especially if you have a lot of members.",
"export-title": "Request a copy of your data", "export-title": "Request a copy of your data",
"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-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-expires-at": "(expires {{expiresAt}})",
"export-download": "Download export", "export-download": "Download export",
"export-request-button": "Request a new export" "export-request-button": "Request a new export",
}, "flag-delete-button": "Delete flag",
"yes": "Yes", "flag-current-flags-title": "Current flags ({{count}}/{{max}})",
"no": "No", "flag-title": "Flags",
"edit-profile": { "flag-upload-title": "Upload a new flag",
"user-header": "Editing your profile", "flag-upload-button": "Upload"
"general-tab": "General", },
"names-pronouns-tab": "Names & pronouns", "yes": "Yes",
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})", "no": "No",
"sid-current": "Current short ID:", "edit-profile": {
"sid": "Short ID", "user-header": "Editing your profile",
"sid-reroll": "Reroll short ID", "general-tab": "General",
"sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.", "names-pronouns-tab": "Names & pronouns",
"sid-copy": "Copy short link", "file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
"update-avatar": "Update avatar", "sid-current": "Current short ID:",
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.", "sid": "Short ID",
"member-header-label": "\"Members\" header text", "sid-reroll": "Reroll short ID",
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.", "sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.",
"hide-member-list-label": "Hide member list", "sid-copy": "Copy short link",
"timezone-label": "Timezone", "update-avatar": "Update avatar",
"timezone-preview": "This will show up on your profile like this:", "avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.", "member-header-label": "\"Members\" header text",
"hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.", "member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
"profile-options-header": "Profile options", "hide-member-list-label": "Hide member list",
"bio-tab": "Bio", "timezone-label": "Timezone",
"saved-changes": "Successfully saved changes!", "timezone-preview": "This will show up on your profile like this:",
"bio-length-hint": "Using {{length}}/{{maxLength}} characters", "timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
"preview": "Preview", "hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.",
"fields-tab": "Fields", "profile-options-header": "Profile options",
"flags-links-tab": "Flags & links", "bio-tab": "Bio",
"back-to-settings-tab": "Back to settings", "saved-changes": "Successfully saved changes!",
"member-header": "Editing profile of {{name}}", "bio-length-hint": "Using {{length}}/{{maxLength}} characters",
"username": "Username", "preview": "Preview",
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.", "fields-tab": "Fields",
"change-username-link": "Go to settings", "flags-links-tab": "Flags & links",
"member-name": "Name", "back-to-settings-tab": "Back to settings",
"change-member-name": "Change name", "member-header": "Editing profile of {{name}}",
"display-name": "Display name", "username": "Username",
"unlisted-label": "Hide from member list", "change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.",
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:", "change-username-link": "Go to settings",
"edit-names-pronouns-header": "Edit names and pronouns", "member-name": "Name",
"back-to-profile-tab": "Back to profile", "change-member-name": "Change name",
"editing-fields-header": "Editing fields" "display-name": "Display name",
}, "unlisted-label": "Hide from member list",
"save-changes": "Save changes", "unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
"change": "Change", "edit-names-pronouns-header": "Edit names and pronouns",
"editor": { "back-to-profile-tab": "Back to profile",
"remove-entry": "Remove entry", "editing-fields-header": "Editing fields"
"move-entry-down": "Move entry down", },
"move-entry-up": "Move entry up", "save-changes": "Save changes",
"add-entry": "Add entry", "change": "Change",
"change-display-text": "Change display text", "editor": {
"display-text-example": "Optional display text (e.g. it/its)", "remove-entry": "Remove entry",
"display-text-label": "Display text", "move-entry-down": "Move entry down",
"display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set.", "move-entry-up": "Move entry up",
"move-field-up": "Move field up", "add-entry": "Add entry",
"move-field-down": "Move field down", "change-display-text": "Change display text",
"remove-field": "Remove field", "display-text-example": "Optional display text (e.g. it/its)",
"field-name": "Field name", "display-text-label": "Display text",
"add-field": "Add field", "display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set.",
"new-entry": "New entry" "move-field-up": "Move field up",
} "move-field-down": "Move field down",
"remove-field": "Remove field",
"field-name": "Field name",
"add-field": "Add field",
"new-entry": "New entry"
},
"cancel": "Cancel"
} }

View file

@ -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 // 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_AVATAR = "https://pronouns.cc/default/512.webp";
export const DEFAULT_FLAG = "/unknown_flag.svg";
export const idTimestamp = (id: string) => export const idTimestamp = (id: string) =>
DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000); DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000);

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

View file

@ -1,7 +1,8 @@
import { apiRequest } from "$api"; import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error.js"; 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 log from "$lib/log.js";
import paginate from "$lib/paginate";
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
const MEMBERS_PER_PAGE = 20; const MEMBERS_PER_PAGE = 20;
@ -20,22 +21,11 @@ export const load = async ({ params, fetch, cookies, url }) => {
throw e; throw e;
} }
// Paginate members on the server side const { data, currentPage, pageCount } = paginate(
let currentPage = 0; user.members,
let pageCount = 0; url.searchParams.get("page"),
let members: PartialMember[] = []; MEMBERS_PER_PAGE,
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,
);
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 };
}; };

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

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

View file

@ -1,18 +1,15 @@
import paginate from "$lib/paginate";
const MEMBERS_PER_PAGE = 15; const MEMBERS_PER_PAGE = 15;
export const load = async ({ url, parent }) => { export const load = async ({ url, parent }) => {
const { user } = await parent(); const { user } = await parent();
let currentPage = Number(url.searchParams.get("page") || "0"); const { data, currentPage, pageCount } = paginate(
let pageCount = Math.ceil(user.members.length / MEMBERS_PER_PAGE); user.members,
let members = user.members.slice( url.searchParams.get("page"),
currentPage * MEMBERS_PER_PAGE, MEMBERS_PER_PAGE,
(currentPage + 1) * 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 };
}; };

View 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

View file

@ -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) taken from [PluralKit](https://github.com/PluralKit/PluralKit/blob/32a6e97342acc3b35e6f9e7b4dd169e21d888770/PluralKit.Core/Database/Functions/functions.sql)
- `Foxnouns.Backend/Database/prune-designer-cs-files.sh`, - `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) 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.