Compare commits

...

2 commits

Author SHA1 Message Date
sam
7791c91960
feat(backend): initial /api/v1/users endpoint 2024-12-25 11:19:50 -05:00
sam
5e7df2e074
feat(frontend): add footer 2024-12-25 11:04:20 -05:00
23 changed files with 484 additions and 7 deletions

View file

@ -22,6 +22,7 @@ using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using XidNet;
namespace Foxnouns.Backend.Controllers; namespace Foxnouns.Backend.Controllers;
@ -64,6 +65,7 @@ public class FlagsController(
var flag = new PrideFlag var flag = new PrideFlag
{ {
Id = snowflakeGenerator.GenerateSnowflake(), Id = snowflakeGenerator.GenerateSnowflake(),
LegacyId = Xid.NewXid().ToString(),
UserId = CurrentUser!.Id, UserId = CurrentUser!.Id,
Name = req.Name, Name = req.Name,
Description = req.Description, Description = req.Description,

View file

@ -26,6 +26,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using NodaTime; using NodaTime;
using XidNet;
namespace Foxnouns.Backend.Controllers; namespace Foxnouns.Backend.Controllers;
@ -101,6 +102,7 @@ public class MembersController(
var member = new Member var member = new Member
{ {
Id = snowflakeGenerator.GenerateSnowflake(), Id = snowflakeGenerator.GenerateSnowflake(),
LegacyId = Xid.NewXid().ToString(),
User = CurrentUser!, User = CurrentUser!,
Name = req.Name, Name = req.Name,
DisplayName = req.DisplayName, DisplayName = req.DisplayName,

View file

@ -222,7 +222,7 @@ public class UsersController(
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key)) .CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
.ToDictionary(); .ToDictionary();
foreach (CustomPreferenceUpdateRequest? r in req) foreach (CustomPreferenceUpdateRequest r in req)
{ {
if (r.Id != null && preferences.ContainsKey(r.Id.Value)) if (r.Id != null && preferences.ContainsKey(r.Id.Value))
{ {
@ -233,6 +233,7 @@ public class UsersController(
Muted = r.Muted, Muted = r.Muted,
Size = r.Size, Size = r.Size,
Tooltip = r.Tooltip, Tooltip = r.Tooltip,
LegacyId = preferences[r.Id.Value].LegacyId,
}; };
} }
else else
@ -244,6 +245,7 @@ public class UsersController(
Muted = r.Muted, Muted = r.Muted,
Size = r.Size, Size = r.Size,
Tooltip = r.Tooltip, Tooltip = r.Tooltip,
LegacyId = Guid.NewGuid(),
}; };
} }
} }

View file

@ -0,0 +1,16 @@
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Services.V1;
using Microsoft.AspNetCore.Mvc;
namespace Foxnouns.Backend.Controllers.V1;
[Route("/api/v1/users")]
public class UsersV1Controller(UsersV1Service usersV1Service) : ApiControllerBase
{
[HttpGet("{userRef}")]
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
{
User user = await usersV1Service.ResolveUserAsync(userRef, CurrentToken, ct);
return Ok(await usersV1Service.RenderUserAsync(user));
}
}

View file

@ -139,6 +139,26 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
modelBuilder modelBuilder
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!) .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!)
.HasName("find_free_member_sid"); .HasName("find_free_member_sid");
// Indexes for legacy IDs for APIv1
modelBuilder.Entity<User>().HasIndex(u => u.LegacyId).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.LegacyId).IsUnique();
modelBuilder.Entity<PrideFlag>().HasIndex(f => f.LegacyId).IsUnique();
// a UUID is not an xid, but this should always be set by the application anyway.
// we're just setting it here to shut EFCore up because squashing migrations is for nerds
modelBuilder
.Entity<User>()
.Property(u => u.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<Member>()
.Property(m => m.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<PrideFlag>()
.Property(f => f.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
} }
/// <summary> /// <summary>

View file

@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241225155818_AddLegacyIds")]
public partial class AddLegacyIds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "users",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "pride_flags",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "members",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.CreateIndex(
name: "ix_users_legacy_id",
table: "users",
column: "legacy_id",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_pride_flags_legacy_id",
table: "pride_flags",
column: "legacy_id",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_members_legacy_id",
table: "members",
column: "legacy_id",
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(name: "ix_users_legacy_id", table: "users");
migrationBuilder.DropIndex(name: "ix_pride_flags_legacy_id", table: "pride_flags");
migrationBuilder.DropIndex(name: "ix_members_legacy_id", table: "members");
migrationBuilder.DropColumn(name: "legacy_id", table: "users");
migrationBuilder.DropColumn(name: "legacy_id", table: "pride_flags");
migrationBuilder.DropColumn(name: "legacy_id", table: "members");
}
}
}

View file

@ -254,6 +254,13 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("fields"); .HasColumnName("fields");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links") b.PrimitiveCollection<string[]>("Links")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
@ -292,6 +299,10 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_members"); .HasName("pk_members");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_members_legacy_id");
b.HasIndex("Sid") b.HasIndex("Sid")
.IsUnique() .IsUnique()
.HasDatabaseName("ix_members_sid"); .HasDatabaseName("ix_members_sid");
@ -386,6 +397,13 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("hash"); .HasColumnName("hash");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")
@ -398,6 +416,10 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_pride_flags"); .HasName("pk_pride_flags");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_pride_flags_legacy_id");
b.HasIndex("UserId") b.HasIndex("UserId")
.HasDatabaseName("ix_pride_flags_user_id"); .HasDatabaseName("ix_pride_flags_user_id");
@ -582,6 +604,13 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("last_sid_reroll"); .HasColumnName("last_sid_reroll");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links") b.PrimitiveCollection<string[]>("Links")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
@ -637,6 +666,10 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_users"); .HasName("pk_users");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_users_legacy_id");
b.HasIndex("Sid") b.HasIndex("Sid")
.IsUnique() .IsUnique()
.HasDatabaseName("ix_users_sid"); .HasDatabaseName("ix_users_sid");

View file

@ -18,6 +18,7 @@ public class Member : BaseModel
{ {
public required string Name { get; set; } public required string Name { get; set; }
public string Sid { get; set; } = string.Empty; public string Sid { get; set; } = string.Empty;
public required string LegacyId { get; init; }
public string? DisplayName { get; set; } public string? DisplayName { get; set; }
public string? Bio { get; set; } public string? Bio { get; set; }
public string? Avatar { get; set; } public string? Avatar { get; set; }

View file

@ -17,6 +17,7 @@ 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 LegacyId { get; init; }
// A null hash means the flag hasn't been processed yet. // A null hash means the flag hasn't been processed yet.
public string? Hash { get; set; } public string? Hash { get; set; }

View file

@ -25,6 +25,7 @@ public class User : BaseModel
{ {
public required string Username { get; set; } public required string Username { get; set; }
public string Sid { get; set; } = string.Empty; public string Sid { get; set; } = string.Empty;
public required string LegacyId { get; init; }
public string? DisplayName { get; set; } public string? DisplayName { get; set; }
public string? Bio { get; set; } public string? Bio { get; set; }
public string? MemberTitle { get; set; } public string? MemberTitle { get; set; }
@ -69,6 +70,8 @@ public class User : BaseModel
// This type is generally serialized directly, so the converter is applied here. // This type is generally serialized directly, so the converter is applied here.
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public PreferenceSize Size { get; set; } public PreferenceSize Size { get; set; }
public Guid LegacyId { get; init; } = Guid.NewGuid();
} }
public static readonly Duration DeleteAfter = Duration.FromDays(30); public static readonly Duration DeleteAfter = Duration.FromDays(30);

View file

@ -36,7 +36,7 @@ public record UserResponse(
IEnumerable<FieldEntry> Names, IEnumerable<FieldEntry> Names,
IEnumerable<Pronoun> Pronouns, IEnumerable<Pronoun> Pronouns,
IEnumerable<Field> Fields, IEnumerable<Field> Fields,
Dictionary<Snowflake, User.CustomPreference> CustomPreferences, Dictionary<Snowflake, CustomPreferenceResponse> CustomPreferences,
IEnumerable<PrideFlagResponse> Flags, IEnumerable<PrideFlagResponse> Flags,
int? UtcOffset, int? UtcOffset,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role, [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
@ -52,6 +52,14 @@ public record UserResponse(
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted
); );
public record CustomPreferenceResponse(
string Icon,
string Tooltip,
bool Muted,
bool Favourite,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] PreferenceSize Size
);
public record AuthMethodResponse( public record AuthMethodResponse(
Snowflake Id, Snowflake Id,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,

View file

@ -0,0 +1,77 @@
// ReSharper disable NotAccessedPositionalProperty.Global
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Services.V1;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
namespace Foxnouns.Backend.Dto.V1;
public record UserResponse(
string Id,
Snowflake IdNew,
string Sid,
string Name,
string? DisplayName,
string? Bio,
string? MemberTitle,
string? Avatar,
string[] Links,
FieldEntry[] Names,
PronounEntry[] Pronouns,
ProfileField[] Fields,
int? UtcOffset,
Dictionary<Guid, CustomPreference> CustomPreferences
);
public record CustomPreference(
string Icon,
string Tooltip,
[property: JsonConverter(typeof(StringEnumConverter), typeof(SnakeCaseNamingStrategy))]
PreferenceSize Size,
bool Muted,
bool Favourite
);
public record ProfileField(string Name, FieldEntry[] Entries)
{
public static ProfileField FromField(
Field field,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) => new(field.Name, FieldEntry.FromEntries(field.Entries, customPreferences));
public static ProfileField[] FromFields(
IEnumerable<Field> fields,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) => fields.Select(f => FromField(f, customPreferences)).ToArray();
}
public record FieldEntry(string Value, string Status)
{
public static FieldEntry[] FromEntries(
IEnumerable<Foxnouns.Backend.Database.Models.FieldEntry> entries,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) =>
entries
.Select(e => new FieldEntry(
e.Value,
V1Utils.TranslateStatus(e.Status, customPreferences)
))
.ToArray();
}
public record PronounEntry(string Pronouns, string? DisplayText, string Status)
{
public static PronounEntry[] FromPronouns(
IEnumerable<Pronoun> pronouns,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) =>
pronouns
.Select(p => new PronounEntry(
p.Value,
p.DisplayText,
V1Utils.TranslateStatus(p.Status, customPreferences)
))
.ToArray();
}

View file

@ -19,6 +19,7 @@ using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth; using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Services.V1;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Minio; using Minio;
using NodaTime; using NodaTime;
@ -127,7 +128,9 @@ public static class WebApplicationExtensions
.AddTransient<MemberAvatarUpdateInvocable>() .AddTransient<MemberAvatarUpdateInvocable>()
.AddTransient<UserAvatarUpdateInvocable>() .AddTransient<UserAvatarUpdateInvocable>()
.AddTransient<CreateFlagInvocable>() .AddTransient<CreateFlagInvocable>()
.AddTransient<CreateDataExportInvocable>(); .AddTransient<CreateDataExportInvocable>()
// Legacy services
.AddScoped<UsersV1Service>();
if (!config.Logging.EnableMetrics) if (!config.Logging.EnableMetrics)
services.AddHostedService<BackgroundMetricsCollectionService>(); services.AddHostedService<BackgroundMetricsCollectionService>();

View file

@ -44,6 +44,7 @@
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6"/> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.6"/>
<PackageReference Include="System.Text.Json" Version="9.0.0"/> <PackageReference Include="System.Text.Json" Version="9.0.0"/>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/> <PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
<PackageReference Include="Yort.Xid.Net" Version="2.0.1"/>
</ItemGroup> </ItemGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation"> <Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">

View file

@ -20,6 +20,7 @@ using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using XidNet;
namespace Foxnouns.Backend.Services.Auth; namespace Foxnouns.Backend.Services.Auth;
@ -70,6 +71,7 @@ public class AuthService(
}, },
LastActive = clock.GetCurrentInstant(), LastActive = clock.GetCurrentInstant(),
Sid = null!, Sid = null!,
LegacyId = Xid.NewXid().ToString(),
}; };
db.Add(user); db.Add(user);
@ -116,6 +118,7 @@ public class AuthService(
}, },
LastActive = clock.GetCurrentInstant(), LastActive = clock.GetCurrentInstant(),
Sid = null!, Sid = null!,
LegacyId = Xid.NewXid().ToString(),
}; };
db.Add(user); db.Add(user);

View file

@ -103,7 +103,8 @@ public class UserRendererService(
user.Names, user.Names,
user.Pronouns, user.Pronouns,
user.Fields, user.Fields,
user.CustomPreferences, user.CustomPreferences.Select(x => (x.Key, RenderCustomPreference(x.Value)))
.ToDictionary(),
flags.Select(f => RenderPrideFlag(f.PrideFlag)), flags.Select(f => RenderPrideFlag(f.PrideFlag)),
utcOffset, utcOffset,
user.Role, user.Role,
@ -130,6 +131,14 @@ public class UserRendererService(
: a.RemoteUsername : a.RemoteUsername
); );
public static CustomPreferenceResponse RenderCustomPreference(User.CustomPreference pref) =>
new(pref.Icon, pref.Tooltip, pref.Muted, pref.Favourite, pref.Size);
public static Dictionary<Snowflake, CustomPreferenceResponse> RenderCustomPreferences(
User user
) =>
user.CustomPreferences.Select(x => (x.Key, RenderCustomPreference(x.Value))).ToDictionary();
public PartialUser RenderPartialUser(User user) => public PartialUser RenderPartialUser(User user) =>
new( new(
user.Id, user.Id,

View file

@ -0,0 +1,92 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto.V1;
using Microsoft.EntityFrameworkCore;
using FieldEntry = Foxnouns.Backend.Dto.V1.FieldEntry;
namespace Foxnouns.Backend.Services.V1;
public class UsersV1Service(DatabaseContext db)
{
public async Task<User> ResolveUserAsync(
string userRef,
Token? token,
CancellationToken ct = default
)
{
if (userRef == "@me")
{
if (token == null)
{
throw new ApiError.Unauthorized(
"This endpoint requires an authenticated user.",
ErrorCode.AuthenticationRequired
);
}
return await db.Users.FirstAsync(u => u.Id == token.UserId, ct);
}
User? user;
if (Snowflake.TryParse(userRef, out Snowflake? sf))
{
user = await db.Users.FirstOrDefaultAsync(u => u.Id == sf && !u.Deleted, ct);
if (user != null)
return user;
}
user = await db.Users.FirstOrDefaultAsync(u => u.LegacyId == userRef && !u.Deleted, ct);
if (user != null)
return user;
user = await db.Users.FirstOrDefaultAsync(u => u.Username == userRef && !u.Deleted, ct);
if (user != null)
return user;
throw new ApiError.NotFound(
"No user with that ID or username found.",
ErrorCode.UserNotFound
);
}
public async Task<UserResponse> RenderUserAsync(User user)
{
int? utcOffset = null;
if (
user.Timezone != null
&& TimeZoneInfo.TryFindSystemTimeZoneById(user.Timezone, out TimeZoneInfo? tz)
)
{
utcOffset = (int)tz.GetUtcOffset(DateTimeOffset.UtcNow).TotalSeconds;
}
return new UserResponse(
user.LegacyId,
user.Id,
user.Sid,
user.Username,
user.DisplayName,
user.Bio,
user.MemberTitle,
user.Avatar,
user.Links,
FieldEntry.FromEntries(user.Names, user.CustomPreferences),
PronounEntry.FromPronouns(user.Pronouns, user.CustomPreferences),
ProfileField.FromFields(user.Fields, user.CustomPreferences),
utcOffset,
user.CustomPreferences.Select(x =>
(
x.Value.LegacyId,
new CustomPreference(
x.Value.Icon,
x.Value.Tooltip,
x.Value.Size,
x.Value.Muted,
x.Value.Favourite
)
)
)
.ToDictionary()
);
}
}

View file

@ -0,0 +1,20 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
namespace Foxnouns.Backend.Services.V1;
public static class V1Utils
{
public static string TranslateStatus(
string status,
Dictionary<Snowflake, User.CustomPreference> customPreferences
)
{
if (!Snowflake.TryParse(status, out Snowflake? sf))
return status;
return customPreferences.TryGetValue(sf.Value, out User.CustomPreference? cf)
? cf.LegacyId.ToString()
: "unknown";
}
}

View file

@ -293,6 +293,12 @@
"System.Runtime": "4.3.1" "System.Runtime": "4.3.1"
} }
}, },
"Yort.Xid.Net": {
"type": "Direct",
"requested": "[2.0.1, )",
"resolved": "2.0.1",
"contentHash": "+3sNX7/RKSKheVuMz9jtWLazD+R4PXpx8va2d9SdDgvKOhETbEb0VYis8K/fD1qm/qOQT57LadToSpzReGMZlw=="
},
"BouncyCastle.Cryptography": { "BouncyCastle.Cryptography": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.5.0", "resolved": "2.5.0",

View file

@ -39,6 +39,7 @@ public class UserMigrator(
_user = new User _user = new User
{ {
Id = goUser.SnowflakeId, Id = goUser.SnowflakeId,
LegacyId = goUser.Id,
Username = goUser.Username, Username = goUser.Username,
DisplayName = goUser.DisplayName, DisplayName = goUser.DisplayName,
Bio = goUser.Bio, Bio = goUser.Bio,
@ -139,6 +140,7 @@ public class UserMigrator(
new PrideFlag new PrideFlag
{ {
Id = flag.SnowflakeId, Id = flag.SnowflakeId,
LegacyId = flag.Id,
UserId = _user!.Id, UserId = _user!.Id,
Hash = flag.Hash, Hash = flag.Hash,
Name = flag.Name, Name = flag.Name,
@ -190,6 +192,7 @@ public class UserMigrator(
UserId = _user!.Id, UserId = _user!.Id,
Name = goMember.Name, Name = goMember.Name,
Sid = goMember.Sid, Sid = goMember.Sid,
LegacyId = goMember.Id,
DisplayName = goMember.DisplayName, DisplayName = goMember.DisplayName,
Bio = goMember.Bio, Bio = goMember.Bio,
Avatar = goMember.Avatar, Avatar = goMember.Avatar,
@ -235,6 +238,7 @@ public class UserMigrator(
"small" => PreferenceSize.Small, "small" => PreferenceSize.Small,
_ => PreferenceSize.Normal, _ => PreferenceSize.Normal,
}, },
LegacyId = new Guid(id),
}; };
} }

View file

@ -64,3 +64,11 @@
max-width: 200px; max-width: 200px;
border-radius: 3px; border-radius: 3px;
} }
.big-footer {
@media (prefers-color-scheme: dark) {
background-color: bootstrap.shade-color(bootstrap.$dark, 20%);
}
background-color: bootstrap.shade-color(bootstrap.$light, 5%);
}

View file

@ -0,0 +1,83 @@
<script lang="ts">
import type { Meta } from "$api/models";
import Git from "svelte-bootstrap-icons/lib/Git.svelte";
import Reception4 from "svelte-bootstrap-icons/lib/Reception4.svelte";
import Newspaper from "svelte-bootstrap-icons/lib/Newspaper.svelte";
import CardText from "svelte-bootstrap-icons/lib/CardText.svelte";
import Shield from "svelte-bootstrap-icons/lib/Shield.svelte";
import Envelope from "svelte-bootstrap-icons/lib/Envelope.svelte";
import CashCoin from "svelte-bootstrap-icons/lib/CashCoin.svelte";
import Logo from "./Logo.svelte";
type Props = { meta: Meta };
let { meta }: Props = $props();
</script>
<footer class="big-footer mt-3 pt-3 pb-1 px-5">
<div class="d-flex flex-column flex-md-row mb-2">
<div class="align-start flex-grow-1">
<Logo />
<ul class="mt-2 list-unstyled">
<li><strong>Version</strong> {meta.version}</li>
</ul>
</div>
<div class="align-end">
<ul class="list-unstyled">
<li>{meta.users.total} <strong>users</strong></li>
<li>{meta.members} <strong>members</strong></li>
</ul>
</div>
</div>
<ul class="list-inline">
<a
class="list-inline-item link-underline link-underline-opacity-0"
target="_blank"
href={meta.repository}
>
<li class="list-inline-item">
<Git />
Source code
</li>
</a>
<a
class="list-inline-item link-underline link-underline-opacity-0"
target="_blank"
href="https://status.pronouns.cc"
>
<li class="list-inline-item">
<Reception4 />
Status
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/about">
<li class="list-inline-item">
<Envelope />
About and contact
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/tos">
<li class="list-inline-item">
<CardText />
Terms of service
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/privacy">
<li class="list-inline-item">
<Shield />
Privacy policy
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/changelog">
<li class="list-inline-item">
<Newspaper />
Changelog
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/donate">
<li class="list-inline-item">
<CashCoin />
Donate
</li>
</a>
</ul>
</footer>

View file

@ -3,11 +3,16 @@
import "../app.scss"; import "../app.scss";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import Navbar from "$components/Navbar.svelte"; import Navbar from "$components/Navbar.svelte";
import Footer from "$components/Footer.svelte";
type Props = { children: Snippet; data: LayoutData }; type Props = { children: Snippet; data: LayoutData };
let { children, data }: Props = $props(); let { children, data }: Props = $props();
</script> </script>
<div class="d-flex flex-column min-vh-100">
<div class="flex-grow-1">
<Navbar user={data.meUser} meta={data.meta} /> <Navbar user={data.meUser} meta={data.meta} />
{@render children?.()} {@render children?.()}
</div>
<Footer meta={data.meta} />
</div>