From 2e2f1bb27be642c61197a66d68eb7aae52cc17a0 Mon Sep 17 00:00:00 2001 From: purifetchi <0xlunaric@gmail.com> Date: Thu, 17 Apr 2025 21:33:53 +0200 Subject: [PATCH 01/15] feat: implement search for posts. not too good, but enough for the initial impl. --- PinkSea/Lexicons/Enums/SearchType.cs | 22 +++++++++++ .../Queries/GetSearchResultsQueryRequest.cs | 35 ++++++++++++++++++ .../Queries/GetSearchResultsQueryResponse.cs | 16 ++++++++ PinkSea/Program.cs | 1 + PinkSea/Services/FeedBuilder.cs | 17 +++++++-- PinkSea/Services/SearchService.cs | 25 +++++++++++++ PinkSea/Xrpc/GetSearchResultsQueryHandler.cs | 37 +++++++++++++++++++ 7 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 PinkSea/Lexicons/Enums/SearchType.cs create mode 100644 PinkSea/Lexicons/Queries/GetSearchResultsQueryRequest.cs create mode 100644 PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs create mode 100644 PinkSea/Services/SearchService.cs create mode 100644 PinkSea/Xrpc/GetSearchResultsQueryHandler.cs diff --git a/PinkSea/Lexicons/Enums/SearchType.cs b/PinkSea/Lexicons/Enums/SearchType.cs new file mode 100644 index 0000000..17e4f84 --- /dev/null +++ b/PinkSea/Lexicons/Enums/SearchType.cs @@ -0,0 +1,22 @@ +namespace PinkSea.Lexicons.Enums; + +/// +/// The search type. +/// +public enum SearchType +{ + /// + /// Searching for posts. + /// + Posts, + + /// + /// Searching for profiles. + /// + Profiles, + + /// + /// Searching for tags. + /// + Tags +} \ No newline at end of file diff --git a/PinkSea/Lexicons/Queries/GetSearchResultsQueryRequest.cs b/PinkSea/Lexicons/Queries/GetSearchResultsQueryRequest.cs new file mode 100644 index 0000000..02ecd12 --- /dev/null +++ b/PinkSea/Lexicons/Queries/GetSearchResultsQueryRequest.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; +using PinkSea.Lexicons.Enums; + +namespace PinkSea.Lexicons.Queries; + +/// +/// The request for the "com.shinolabs.pinksea.getSearchResults" xrpc call. +/// +public class GetSearchResultsQueryRequest +{ + /// + /// The query we're looking for. + /// + [JsonPropertyName("query")] + public required string Query { get; set; } + + /// + /// The search type. + /// + [JsonPropertyName("type")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public required SearchType Type { get; set; } + + /// + /// Since when should we query. + /// + [JsonPropertyName("since")] + public DateTimeOffset? Since { get; set; } + + /// + /// The limit on values to fetch. + /// + [JsonPropertyName("limit")] + public int Limit { get; set; } = 50; +} \ No newline at end of file diff --git a/PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs b/PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs new file mode 100644 index 0000000..40e4243 --- /dev/null +++ b/PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using PinkSea.Lexicons.Objects; + +namespace PinkSea.Lexicons.Queries; + +/// +/// The response for the "com.shinolabs.pinksea.getSearchResults" XRPC call. +/// +public class GetSearchResultsQueryResponse +{ + /// + /// The list of oekaki. + /// + [JsonPropertyName("oekaki")] + public IReadOnlyList? Oekaki { get; set; } +} \ No newline at end of file diff --git a/PinkSea/Program.cs b/PinkSea/Program.cs index 5ddfc20..94fc558 100644 --- a/PinkSea/Program.cs +++ b/PinkSea/Program.cs @@ -34,6 +34,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddTransient(); builder.Services.AddDbContext(); diff --git a/PinkSea/Services/FeedBuilder.cs b/PinkSea/Services/FeedBuilder.cs index 2a44a1a..98c0a02 100644 --- a/PinkSea/Services/FeedBuilder.cs +++ b/PinkSea/Services/FeedBuilder.cs @@ -98,13 +98,12 @@ public FeedBuilder Limit(int count) } /// - /// Gets the feed. + /// Builds a feed from a list of /// + /// The oekaki models. /// The list of oekaki DTOs. - public async Task> GetFeed() + public async Task> FromOekakiModelList(IList list) { - var list = await _query.ToListAsync(); - var dids = list.Select(o => o.AuthorDid); var map = new ConcurrentDictionary(); @@ -122,4 +121,14 @@ public async Task> GetFeed() return oekakiDtos; } + + /// + /// Gets the feed. + /// + /// The list of oekaki DTOs. + public async Task> GetFeed() + { + var list = await _query.ToListAsync(); + return await FromOekakiModelList(list); + } } \ No newline at end of file diff --git a/PinkSea/Services/SearchService.cs b/PinkSea/Services/SearchService.cs new file mode 100644 index 0000000..b5a4214 --- /dev/null +++ b/PinkSea/Services/SearchService.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using PinkSea.Database; +using PinkSea.Database.Models; + +namespace PinkSea.Services; + +public class SearchService( + PinkSeaDbContext dbContext) +{ + public async Task> SearchPosts(string query, int limit, DateTimeOffset since) + { + var list = await dbContext.Oekaki + .Include(o => o.TagOekakiRelations) + .Include(o => o.Author) + .Where(o => !o.Tombstone) + .Where(o => o.AltText!.ToLower().Contains(query.ToLower()) || + o.TagOekakiRelations!.Any(to => to.TagId.ToLower().Contains(query.ToLower()))) // TODO: Author + .OrderByDescending(o => o.IndexedAt) + .Where(o => o.IndexedAt < since) + .Take(limit) + .ToListAsync(); + + return list; + } +} \ No newline at end of file diff --git a/PinkSea/Xrpc/GetSearchResultsQueryHandler.cs b/PinkSea/Xrpc/GetSearchResultsQueryHandler.cs new file mode 100644 index 0000000..c3d7609 --- /dev/null +++ b/PinkSea/Xrpc/GetSearchResultsQueryHandler.cs @@ -0,0 +1,37 @@ +using PinkSea.AtProto.Server.Xrpc; +using PinkSea.AtProto.Shared.Xrpc; +using PinkSea.Lexicons.Enums; +using PinkSea.Lexicons.Queries; +using PinkSea.Services; + +namespace PinkSea.Xrpc; + +/// +/// The handler for the "com.shinolabs.pinksea.getSearchResults" xrpc query. Gets the search results for a value. +/// +[Xrpc("com.shinolabs.pinksea.getSearchResults")] +public class GetSearchResultsQueryHandler( + SearchService searchService, + FeedBuilder feedBuilder) : IXrpcQuery +{ + /// + public async Task> Handle(GetSearchResultsQueryRequest request) + { + var limit = Math.Clamp(request.Limit, 1, 50); + var since = request.Since ?? DateTimeOffset.UtcNow.AddMinutes(5); + + if (request.Type == SearchType.Posts) + { + var posts = await searchService.SearchPosts(request.Query, limit, since); + var models = await feedBuilder.FromOekakiModelList(posts); + + var result = new GetSearchResultsQueryResponse + { + Oekaki = models + }; + return XrpcErrorOr.Ok(result); + } + + return XrpcErrorOr.Fail("InvalidSearchType", "Invalid search type."); + } +} \ No newline at end of file From a8cd62ec5139f524ea16dee29fcfac73c441fad0 Mon Sep 17 00:00:00 2001 From: purifetchi <0xlunaric@gmail.com> Date: Fri, 18 Apr 2025 13:00:39 +0200 Subject: [PATCH 02/15] feat: tag search. --- PinkSea/Lexicons/Objects/TagSearchResult.cs | 21 +++++++++ .../Queries/GetSearchResultsQueryResponse.cs | 6 +++ PinkSea/Services/SearchService.cs | 46 ++++++++++++++++++- PinkSea/Xrpc/GetSearchResultsQueryHandler.cs | 12 +++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 PinkSea/Lexicons/Objects/TagSearchResult.cs diff --git a/PinkSea/Lexicons/Objects/TagSearchResult.cs b/PinkSea/Lexicons/Objects/TagSearchResult.cs new file mode 100644 index 0000000..a106595 --- /dev/null +++ b/PinkSea/Lexicons/Objects/TagSearchResult.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace PinkSea.Lexicons.Objects; + +/// +/// A tag search result. +/// +public class TagSearchResult +{ + /// + /// The tag. + /// + [JsonPropertyName("tag")] + public required string Tag { get; set; } + + /// + /// The oekaki to represent it. + /// + [JsonPropertyName("oekaki")] + public required HydratedOekaki Oekaki { get; set; } +} \ No newline at end of file diff --git a/PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs b/PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs index 40e4243..d9b7419 100644 --- a/PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs +++ b/PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs @@ -13,4 +13,10 @@ public class GetSearchResultsQueryResponse /// [JsonPropertyName("oekaki")] public IReadOnlyList? Oekaki { get; set; } + + /// + /// The list of tags. + /// + [JsonPropertyName("tags")] + public IReadOnlyList? Tags { get; set; } } \ No newline at end of file diff --git a/PinkSea/Services/SearchService.cs b/PinkSea/Services/SearchService.cs index b5a4214..4261c4e 100644 --- a/PinkSea/Services/SearchService.cs +++ b/PinkSea/Services/SearchService.cs @@ -1,11 +1,18 @@ +using System.Collections.Concurrent; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using PinkSea.AtProto.Resolvers.Did; using PinkSea.Database; using PinkSea.Database.Models; +using PinkSea.Lexicons.Objects; +using PinkSea.Models; namespace PinkSea.Services; public class SearchService( - PinkSeaDbContext dbContext) + PinkSeaDbContext dbContext, + IDidResolver didResolver, + IOptions opts) { public async Task> SearchPosts(string query, int limit, DateTimeOffset since) { @@ -22,4 +29,41 @@ public async Task> SearchPosts(string query, int limit, DateTi return list; } + + public async Task> SearchTags(string query, int limit, DateTimeOffset since) + { + var list = await dbContext.Tags + .Where(t => t.Name.ToLower().Contains(query.ToLower())) + .Join(dbContext.TagOekakiRelations, t => t.Name, to => to.TagId, (t, to) => new { t, to }) + .Join(dbContext.Oekaki, c => c.to.OekakiId, o => o.Key, (c, o) => new { c.t, c.to, o }) + .GroupBy(c => c.t.Name) + .Take(limit) + .Select(c => new + { + Tag = c.Key, + Oekaki = c.First().o + }) + .ToListAsync(); + + var dids = list.Select(o => o.Oekaki.AuthorDid); + var map = new ConcurrentDictionary(); + + await Parallel.ForEachAsync(dids, new ParallelOptions + { + MaxDegreeOfParallelism = 5 + }, async (did, _) => + { + map[did] = await didResolver.GetHandleFromDid(did) ?? "invalid.handle"; + }); + + var oekakiDtos = list + .Select(o => new TagSearchResult + { + Tag = o.Tag, + Oekaki = HydratedOekaki.FromOekakiModel(o.Oekaki, map[o.Oekaki.AuthorDid], opts.Value.ImageProxyTemplate) + }) + .ToList(); + + return oekakiDtos; + } } \ No newline at end of file diff --git a/PinkSea/Xrpc/GetSearchResultsQueryHandler.cs b/PinkSea/Xrpc/GetSearchResultsQueryHandler.cs index c3d7609..905324f 100644 --- a/PinkSea/Xrpc/GetSearchResultsQueryHandler.cs +++ b/PinkSea/Xrpc/GetSearchResultsQueryHandler.cs @@ -32,6 +32,18 @@ public async Task> Handle(GetSearchRe return XrpcErrorOr.Ok(result); } + if (request.Type == SearchType.Tags) + { + var tags = await searchService.SearchTags(request.Query, limit, since); + + var result = new GetSearchResultsQueryResponse + { + Tags = tags + }; + + return XrpcErrorOr.Ok(result); + } + return XrpcErrorOr.Fail("InvalidSearchType", "Invalid search type."); } } \ No newline at end of file From c05a8dc998275a2428b6f2131478857a544fb8e8 Mon Sep 17 00:00:00 2001 From: purifetchi <0xlunaric@gmail.com> Date: Fri, 18 Apr 2025 13:54:08 +0200 Subject: [PATCH 03/15] feat: store the handle in the db. --- .../HttpResponseMessageExtensions.cs | 30 ++- PinkSea/Database/Models/UserModel.cs | 5 + ...10405_Add handle to user model.Designer.cs | 211 ++++++++++++++++++ ...20250418110405_Add handle to user model.cs | 28 +++ .../PinkSeaDbContextModelSnapshot.cs | 3 + PinkSea/Program.cs | 1 + .../Services/OekakiJetStreamEventHandler.cs | 38 +++- PinkSea/Services/OekakiService.cs | 17 +- PinkSea/Services/UserService.cs | 69 ++++++ 9 files changed, 381 insertions(+), 21 deletions(-) create mode 100644 PinkSea/Migrations/20250418110405_Add handle to user model.Designer.cs create mode 100644 PinkSea/Migrations/20250418110405_Add handle to user model.cs create mode 100644 PinkSea/Services/UserService.cs diff --git a/PinkSea.AtProto/Xrpc/Extensions/HttpResponseMessageExtensions.cs b/PinkSea.AtProto/Xrpc/Extensions/HttpResponseMessageExtensions.cs index 60a483f..7a80f16 100644 --- a/PinkSea.AtProto/Xrpc/Extensions/HttpResponseMessageExtensions.cs +++ b/PinkSea.AtProto/Xrpc/Extensions/HttpResponseMessageExtensions.cs @@ -23,7 +23,17 @@ public static async Task> ReadXrpcResponse( var body = await message.Content.ReadAsStringAsync(); if (!message.IsSuccessStatusCode) { - var error = JsonSerializer.Deserialize(body) ?? new XrpcError + XrpcError? error = null; + try + { + error = JsonSerializer.Deserialize(body); + } + catch (Exception e) + { + logger?.LogError(e, "Failed to deserialize JSON when parsing XRPC result."); + } + + error ??= new XrpcError { Error = "UnknownError", Message = body @@ -43,7 +53,21 @@ public static async Task> ReadXrpcResponse( if (message is TResponse typedResponse) return XrpcErrorOr.Ok(typedResponse); - var result = JsonSerializer.Deserialize(body); - return XrpcErrorOr.Ok(result!); + try + { + var result = JsonSerializer.Deserialize(body); + return XrpcErrorOr.Ok(result!); + } + catch (Exception e) + { + logger?.LogError(e, "Failed to deserialize JSON when parsing XRPC result."); + var error = new XrpcError + { + Error = "UnknownError", + Message = body + }; + + return XrpcErrorOr.Fail(error.Error, error.Message); + } } } \ No newline at end of file diff --git a/PinkSea/Database/Models/UserModel.cs b/PinkSea/Database/Models/UserModel.cs index 212ad51..6417823 100644 --- a/PinkSea/Database/Models/UserModel.cs +++ b/PinkSea/Database/Models/UserModel.cs @@ -13,6 +13,11 @@ public class UserModel [Key] public required string Did { get; set; } + /// + /// The cached handle of the user. May be out of date if an account event was skipped. + /// + public string? Handle { get; set; } + /// /// When was this user created at? /// diff --git a/PinkSea/Migrations/20250418110405_Add handle to user model.Designer.cs b/PinkSea/Migrations/20250418110405_Add handle to user model.Designer.cs new file mode 100644 index 0000000..9fe7575 --- /dev/null +++ b/PinkSea/Migrations/20250418110405_Add handle to user model.Designer.cs @@ -0,0 +1,211 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PinkSea.Database; + +#nullable disable + +namespace PinkSea.Migrations +{ + [DbContext(typeof(PinkSeaDbContext))] + [Migration("20250418110405_Add handle to user model")] + partial class Addhandletousermodel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PinkSea.Database.Models.ConfigurationModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientPrivateKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClientPublicKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Configuration"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.OAuthStateModel", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Json") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("OAuthStates"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("AltText") + .HasColumnType("text"); + + b.Property("AuthorDid") + .IsRequired() + .HasColumnType("text"); + + b.Property("BlobCid") + .IsRequired() + .HasColumnType("text"); + + b.Property("BlueskyCrosspostRecordTid") + .HasColumnType("text"); + + b.Property("IndexedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsNsfw") + .HasColumnType("boolean"); + + b.Property("OekakiTid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("text"); + + b.Property("RecordCid") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tombstone") + .HasColumnType("boolean"); + + b.HasKey("Key"); + + b.HasIndex("ParentId"); + + b.HasIndex("Tombstone"); + + b.HasIndex("AuthorDid", "OekakiTid"); + + b.ToTable("Oekaki"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.TagModel", b => + { + b.Property("Name") + .HasColumnType("text"); + + b.HasKey("Name"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.TagOekakiRelationModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("OekakiId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TagId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OekakiId"); + + b.HasIndex("TagId"); + + b.ToTable("TagOekakiRelations"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.UserModel", b => + { + b.Property("Did") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Handle") + .HasColumnType("text"); + + b.HasKey("Did"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b => + { + b.HasOne("PinkSea.Database.Models.UserModel", "Author") + .WithMany() + .HasForeignKey("AuthorDid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PinkSea.Database.Models.OekakiModel", "Parent") + .WithMany() + .HasForeignKey("ParentId"); + + b.Navigation("Author"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.TagOekakiRelationModel", b => + { + b.HasOne("PinkSea.Database.Models.OekakiModel", "Oekaki") + .WithMany("TagOekakiRelations") + .HasForeignKey("OekakiId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PinkSea.Database.Models.TagModel", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Oekaki"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b => + { + b.Navigation("TagOekakiRelations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PinkSea/Migrations/20250418110405_Add handle to user model.cs b/PinkSea/Migrations/20250418110405_Add handle to user model.cs new file mode 100644 index 0000000..d91db98 --- /dev/null +++ b/PinkSea/Migrations/20250418110405_Add handle to user model.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PinkSea.Migrations +{ + /// + public partial class Addhandletousermodel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Handle", + table: "Users", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Handle", + table: "Users"); + } + } +} diff --git a/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs b/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs index dd2c85b..06093e6 100644 --- a/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs +++ b/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs @@ -154,6 +154,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Handle") + .HasColumnType("text"); + b.HasKey("Did"); b.ToTable("Users"); diff --git a/PinkSea/Program.cs b/PinkSea/Program.cs index 94fc558..edb94e8 100644 --- a/PinkSea/Program.cs +++ b/PinkSea/Program.cs @@ -35,6 +35,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddTransient(); builder.Services.AddDbContext(); diff --git a/PinkSea/Services/OekakiJetStreamEventHandler.cs b/PinkSea/Services/OekakiJetStreamEventHandler.cs index 0f32210..67b2ba0 100644 --- a/PinkSea/Services/OekakiJetStreamEventHandler.cs +++ b/PinkSea/Services/OekakiJetStreamEventHandler.cs @@ -13,18 +13,32 @@ namespace PinkSea.Services; /// public class OekakiJetStreamEventHandler( OekakiService oekakiService, + UserService userService, IDidResolver didResolver, IHttpClientFactory httpClientFactory, ILogger logger, IMemoryCache memoryCache) : IJetStreamEventHandler { /// - public async Task HandleEvent(JetStreamEvent @event) + public Task HandleEvent(JetStreamEvent @event) { - if (@event.Kind != "commit") - return; + return @event.Kind switch + { + "commit" => HandleCommit(@event, @event.Commit!), + "identity" => HandleIdentity(@event, @event.Identity!), + _ => Task.CompletedTask + }; + } - var commit = @event.Commit!; + /// + /// Handles a commit event. + /// + /// The event. + /// The commit. + private async Task HandleCommit( + JetStreamEvent @event, + AtProtoCommit commit) + { if (commit.Operation == "create") { await ProcessCreatedOekaki( @@ -39,6 +53,22 @@ await ProcessDeletedOekaki( } } + /// + /// Handles the identity event. + /// + /// The event. + /// The identity data. + private async Task HandleIdentity( + JetStreamEvent @event, + AtProtoIdentity identity) + { + if (!await userService.UserExists(@event.Did)) + return; + + if (!string.IsNullOrEmpty(identity.Handle)) + await userService.UpdateHandle(@event.Did, identity.Handle); + } + /// /// Processes deleted oekaki. /// diff --git a/PinkSea/Services/OekakiService.cs b/PinkSea/Services/OekakiService.cs index 93770a0..0f9d696 100644 --- a/PinkSea/Services/OekakiService.cs +++ b/PinkSea/Services/OekakiService.cs @@ -28,6 +28,7 @@ public partial class OekakiService( IDomainDidResolver didResolver, PinkSeaDbContext dbContext, TagsService tagsService, + UserService userService, IMemoryCache memoryCache) { /// @@ -221,20 +222,8 @@ public async Task InsertOekakiIntoDatabase( bool useRecordIndexedAt = false) { // First, see if the author exists. - var author = await dbContext.Users - .FirstOrDefaultAsync(u => u.Did == authorDid); - - if (author is null) - { - author = new UserModel - { - Did = authorDid, - CreatedAt = DateTimeOffset.UtcNow - }; - - await dbContext.Users.AddAsync(author); - await dbContext.SaveChangesAsync(); - } + var author = await dbContext.Users.FirstOrDefaultAsync(u => u.Did == authorDid) + ?? await userService.Create(authorDid); var indexed = DateTimeOffset.UtcNow; if (useRecordIndexedAt) diff --git a/PinkSea/Services/UserService.cs b/PinkSea/Services/UserService.cs new file mode 100644 index 0000000..2a8ca70 --- /dev/null +++ b/PinkSea/Services/UserService.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; +using PinkSea.AtProto.Resolvers.Did; +using PinkSea.Database; +using PinkSea.Database.Models; + +namespace PinkSea.Services; + +/// +/// The service responsible for managing users. +/// +public class UserService( + PinkSeaDbContext dbContext, + IDidResolver didResolver, + ILogger logger) +{ + /// + /// Checks whether a given user exists. + /// + /// The user's DID. + /// Whether they exist. + public async Task UserExists( + string did) + { + return await dbContext.Users + .AnyAsync(u => u.Did == did); + } + + /// + /// Creates a new user. + /// + /// The DID of the user. + /// The user model. + public async Task Create( + string did) + { + var handle = await didResolver.GetHandleFromDid(did); + logger.LogInformation("Creating a new user with DID {Did} and handle {Handle}", + did, handle); + + var author = new UserModel + { + Did = did, + Handle = handle, + CreatedAt = DateTimeOffset.UtcNow + }; + + await dbContext.Users.AddAsync(author); + await dbContext.SaveChangesAsync(); + + return author; + } + + /// + /// Updates the handle for a user. + /// + /// The DID of the user. + /// The new handle of said user. + public async Task UpdateHandle( + string did, + string newHandle) + { + logger.LogInformation("Updating the handle of {Did}. New handle is @{Handle}", + did, newHandle); + + await dbContext.Users + .Where(u => u.Did == did) + .ExecuteUpdateAsync(sp => sp.SetProperty(u => u.Handle, newHandle)); + } +} \ No newline at end of file From 85a194327235c10b351e1cdaca33bc7148796bc4 Mon Sep 17 00:00:00 2001 From: purifetchi <0xlunaric@gmail.com> Date: Fri, 18 Apr 2025 14:10:00 +0200 Subject: [PATCH 04/15] feat: add profile search. --- .../Queries/GetSearchResultsQueryResponse.cs | 10 ++++++++-- PinkSea/Services/SearchService.cs | 16 ++++++++++++++++ PinkSea/Xrpc/GetSearchResultsQueryHandler.cs | 12 ++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs b/PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs index d9b7419..44a369d 100644 --- a/PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs +++ b/PinkSea/Lexicons/Queries/GetSearchResultsQueryResponse.cs @@ -12,11 +12,17 @@ public class GetSearchResultsQueryResponse /// The list of oekaki. /// [JsonPropertyName("oekaki")] - public IReadOnlyList? Oekaki { get; set; } + public IReadOnlyList? Oekaki { get; set; } = []; /// /// The list of tags. /// [JsonPropertyName("tags")] - public IReadOnlyList? Tags { get; set; } + public IReadOnlyList? Tags { get; set; } = []; + + /// + /// The list of profiles. + /// + [JsonPropertyName("profiles")] + public IReadOnlyList? Profiles { get; set; } = []; } \ No newline at end of file diff --git a/PinkSea/Services/SearchService.cs b/PinkSea/Services/SearchService.cs index 4261c4e..3a237d0 100644 --- a/PinkSea/Services/SearchService.cs +++ b/PinkSea/Services/SearchService.cs @@ -66,4 +66,20 @@ public async Task> SearchTags(string query, int limit, Dat return oekakiDtos; } + + public async Task> SearchAccounts(string query, int limit, DateTimeOffset since) + { + var list = await dbContext.Users + .Where(u => u.Handle != null && u.Handle.ToLower().Contains(query)) + .OrderByDescending(u => u.CreatedAt) + .Where(u => u.CreatedAt < since) + .Take(limit) + .ToListAsync(); + + return list.Select(u => new Author + { + Did = u.Did, + Handle = u.Handle ?? "invalid.handle" + }).ToList(); + } } \ No newline at end of file diff --git a/PinkSea/Xrpc/GetSearchResultsQueryHandler.cs b/PinkSea/Xrpc/GetSearchResultsQueryHandler.cs index 905324f..525ba2e 100644 --- a/PinkSea/Xrpc/GetSearchResultsQueryHandler.cs +++ b/PinkSea/Xrpc/GetSearchResultsQueryHandler.cs @@ -44,6 +44,18 @@ public async Task> Handle(GetSearchRe return XrpcErrorOr.Ok(result); } + if (request.Type == SearchType.Profiles) + { + var profiles = await searchService.SearchAccounts(request.Query, limit, since); + + var result = new GetSearchResultsQueryResponse + { + Profiles = profiles + }; + + return XrpcErrorOr.Ok(result); + } + return XrpcErrorOr.Fail("InvalidSearchType", "Invalid search type."); } } \ No newline at end of file From 744eb6c774ea4d95fb1c7b09d29ed55a118d063a Mon Sep 17 00:00:00 2001 From: purifetchi <0xlunaric@gmail.com> Date: Fri, 18 Apr 2025 14:15:00 +0200 Subject: [PATCH 05/15] feat: add the post count to the tag search result. --- PinkSea/Lexicons/Objects/TagSearchResult.cs | 6 ++++++ PinkSea/Services/SearchService.cs | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/PinkSea/Lexicons/Objects/TagSearchResult.cs b/PinkSea/Lexicons/Objects/TagSearchResult.cs index a106595..a6f97d7 100644 --- a/PinkSea/Lexicons/Objects/TagSearchResult.cs +++ b/PinkSea/Lexicons/Objects/TagSearchResult.cs @@ -18,4 +18,10 @@ public class TagSearchResult /// [JsonPropertyName("oekaki")] public required HydratedOekaki Oekaki { get; set; } + + /// + /// How many posts have this tag? + /// + [JsonPropertyName("count")] + public required int Count { get; set; } } \ No newline at end of file diff --git a/PinkSea/Services/SearchService.cs b/PinkSea/Services/SearchService.cs index 3a237d0..3615345 100644 --- a/PinkSea/Services/SearchService.cs +++ b/PinkSea/Services/SearchService.cs @@ -41,7 +41,8 @@ public async Task> SearchTags(string query, int limit, Dat .Select(c => new { Tag = c.Key, - Oekaki = c.First().o + Oekaki = c.First().o, + Count = c.Count() }) .ToListAsync(); @@ -60,7 +61,8 @@ public async Task> SearchTags(string query, int limit, Dat .Select(o => new TagSearchResult { Tag = o.Tag, - Oekaki = HydratedOekaki.FromOekakiModel(o.Oekaki, map[o.Oekaki.AuthorDid], opts.Value.ImageProxyTemplate) + Oekaki = HydratedOekaki.FromOekakiModel(o.Oekaki, map[o.Oekaki.AuthorDid], opts.Value.ImageProxyTemplate), + Count = o.Count }) .ToList(); From 010e69096e4f085da302f65f7ae37bc408e18fec Mon Sep 17 00:00:00 2001 From: naomiEve <0xlunaric@gmail.com> Date: Fri, 25 Apr 2025 13:12:54 +0200 Subject: [PATCH 06/15] feat: implement repo status and listen to account events on jetstream. --- PinkSea.Frontend/src/api/atproto/lexicons.ts | 23 ++ PinkSea.Frontend/src/views/UserView.vue | 47 +++- PinkSea/Database/Models/UserModel.cs | 19 ++ PinkSea/Database/Models/UserRepoStatus.cs | 11 + PinkSea/Database/PinkSeaDbContext.cs | 8 + ...us and app view blocked status.Designer.cs | 217 ++++++++++++++++++ ...repo status and app view blocked status.cs | 40 ++++ .../PinkSeaDbContextModelSnapshot.cs | 6 + .../Services/OekakiJetStreamEventHandler.cs | 40 ++++ PinkSea/Services/OekakiService.cs | 29 ++- PinkSea/Services/UserService.cs | 29 +++ PinkSea/Xrpc/GetProfileQueryHandler.cs | 25 +- 12 files changed, 480 insertions(+), 14 deletions(-) create mode 100644 PinkSea/Database/Models/UserRepoStatus.cs create mode 100644 PinkSea/Migrations/20250425103946_Add repo status and app view blocked status.Designer.cs create mode 100644 PinkSea/Migrations/20250425103946_Add repo status and app view blocked status.cs diff --git a/PinkSea.Frontend/src/api/atproto/lexicons.ts b/PinkSea.Frontend/src/api/atproto/lexicons.ts index 76bc36c..fc2f5cb 100644 --- a/PinkSea.Frontend/src/api/atproto/lexicons.ts +++ b/PinkSea.Frontend/src/api/atproto/lexicons.ts @@ -101,6 +101,25 @@ declare module '@atcute/client/lexicons' { } } + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace ComShinolabsPinkseaUnspeccedGetProfile { + interface Params { + did: string + } + + interface Output { + did: string, + handle: string, + nick: string, + description: string, + links: [{ + name: string, + url: string + }], + avatar: string + } + } + interface Queries { 'com.shinolabs.pinksea.getRecent': { params: GenericTimelineQueryRequest, @@ -133,6 +152,10 @@ declare module '@atcute/client/lexicons' { 'com.shinolabs.pinksea.getParentForReply': { params: ComShinolabsPinkseaGetParentForReply.Params, output: ComShinolabsPinkseaGetParentForReply.Params + }, + 'com.shinolabs.pinksea.unspecced.getProfile': { + params: ComShinolabsPinkseaUnspeccedGetProfile.Params, + output: ComShinolabsPinkseaUnspeccedGetProfile.Output } } diff --git a/PinkSea.Frontend/src/views/UserView.vue b/PinkSea.Frontend/src/views/UserView.vue index f6e76d8..5d04fa4 100644 --- a/PinkSea.Frontend/src/views/UserView.vue +++ b/PinkSea.Frontend/src/views/UserView.vue @@ -6,6 +6,7 @@ import { computed, ref, watch } from 'vue' import { xrpc } from '@/api/atproto/client' import { useRoute } from 'vue-router' import { UserProfileTab } from '@/models/user-profile-tab' +import { XRPCError } from '@atcute/client' const tabs = [ { @@ -21,11 +22,27 @@ const tabs = [ const handle = ref(""); const route = useRoute(); +const exists = ref(null); +const profileError = ref(""); + const currentTab = ref(UserProfileTab.Posts); watch(() => route.params.did, async () => { - const { data } = await xrpc.get("com.shinolabs.pinksea.getHandleFromDid", { params: { did: route.params.did as string }}); - handle.value = data.handle; + try { + const { data } = await xrpc.get("com.shinolabs.pinksea.unspecced.getProfile", { params: { did: route.params.did as string }}); + handle.value = data.handle; + exists.value = true; + } catch (e) { + if (e instanceof XRPCError) { + const xrpcError = e as XRPCError; + profileError.value = xrpcError.description ?? "An unknown error has occurred."; + } else { + profileError.value = "Failed to load the profile."; + } + + exists.value = false; + } + }, { immediate: true }); const bskyUrl = computed(() => { @@ -40,17 +57,25 @@ const domainUrl = computed(() => { diff --git a/PinkSea.Frontend/src/components/search/SearchProfileCard.vue b/PinkSea.Frontend/src/components/search/SearchProfileCard.vue new file mode 100644 index 0000000..69e6b7d --- /dev/null +++ b/PinkSea.Frontend/src/components/search/SearchProfileCard.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/PinkSea.Frontend/src/components/search/SearchTag.vue b/PinkSea.Frontend/src/components/search/SearchTag.vue new file mode 100644 index 0000000..6b4d304 --- /dev/null +++ b/PinkSea.Frontend/src/components/search/SearchTag.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/PinkSea.Frontend/src/intl/translations/en.json b/PinkSea.Frontend/src/intl/translations/en.json index bc6037d..17a3437 100644 --- a/PinkSea.Frontend/src/intl/translations/en.json +++ b/PinkSea.Frontend/src/intl/translations/en.json @@ -27,7 +27,13 @@ "settings": "your settings", "user_profile": "{{handle}}'s profile", "user_post": "{{handle}}'s post", - "tagged": "posts tagged #{{tag}}" + "tagged": "posts tagged #{{tag}}", + "search": "searching for {{value}}" + }, + "search": { + "posts_tab": "Posts", + "tags_tab": "Tags", + "profiles_tab": "Profiles" }, "timeline": { "by_before_handle": "By ", diff --git a/PinkSea.Frontend/src/layouts/PanelLayout.vue b/PinkSea.Frontend/src/layouts/PanelLayout.vue index 092f025..dceb294 100644 --- a/PinkSea.Frontend/src/layouts/PanelLayout.vue +++ b/PinkSea.Frontend/src/layouts/PanelLayout.vue @@ -58,15 +58,13 @@ const openPainter = async () => { }; const navigateTo = async (url: string) => { - if(url == "/tag/") return; // prevent using the search box with no query + if (url == "/search/") { + return; // prevent using the search box with no query + } await router.push(url); }; -const queryToTag = (q: string) => { - return q.replace(/\s/g, "_"); -} - const logout = async () => { try { await xrpc.call("com.shinolabs.pinksea.invalidateSession", { @@ -110,7 +108,7 @@ const getCreateSomethingButtonName = computed(() => { {{ $t("menu.search") }}
- +
diff --git a/PinkSea.Frontend/src/models/search-type.ts b/PinkSea.Frontend/src/models/search-type.ts new file mode 100644 index 0000000..86c4931 --- /dev/null +++ b/PinkSea.Frontend/src/models/search-type.ts @@ -0,0 +1,5 @@ +export enum SearchType { + Posts, + Profiles, + Tags +} diff --git a/PinkSea.Frontend/src/models/tag-search-result.ts b/PinkSea.Frontend/src/models/tag-search-result.ts new file mode 100644 index 0000000..789f1c8 --- /dev/null +++ b/PinkSea.Frontend/src/models/tag-search-result.ts @@ -0,0 +1,7 @@ +import type { Oekaki } from '@/models/oekaki' + +export interface TagSearchResult { + tag: string, + oekaki: Oekaki, + count: number +} diff --git a/PinkSea.Frontend/src/router/index.ts b/PinkSea.Frontend/src/router/index.ts index c35c585..561b486 100644 --- a/PinkSea.Frontend/src/router/index.ts +++ b/PinkSea.Frontend/src/router/index.ts @@ -10,6 +10,7 @@ import TagView from '@/views/TagView.vue' import SettingsView from '@/views/SettingsView.vue' import i18next from 'i18next' import { withTegakiViewBackProtection } from '@/api/tegaki/tegaki-view-helper' +import SearchView from '@/views/SearchView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -80,6 +81,16 @@ const router = createRouter({ return { name: 'breadcrumb.settings' }; } } + }, + { + path: '/search/:value', + name: 'search', + component: SearchView, + meta: { + resolveBreadcrumb: async (route: RouteParamsGeneric) => { + return { name: "breadcrumb.search", params: { value: route.value } }; + } + } } ] }); diff --git a/PinkSea.Frontend/src/views/SearchView.vue b/PinkSea.Frontend/src/views/SearchView.vue new file mode 100644 index 0000000..cda689e --- /dev/null +++ b/PinkSea.Frontend/src/views/SearchView.vue @@ -0,0 +1,114 @@ + + + + + From 857f181aff7ab27024c95d3f41a6338383d466a7 Mon Sep 17 00:00:00 2001 From: naomiEve <0xlunaric@gmail.com> Date: Wed, 7 May 2025 10:44:51 +0200 Subject: [PATCH 09/15] refactor: move the tombstone card into a common error card component. --- .../PostViewOekakiTombstoneCard.vue => ErrorCard.vue} | 8 ++++++-- PinkSea.Frontend/src/views/PostView.vue | 5 +++-- PinkSea.Frontend/src/views/UserView.vue | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) rename PinkSea.Frontend/src/components/{oekaki/PostViewOekakiTombstoneCard.vue => ErrorCard.vue} (84%) diff --git a/PinkSea.Frontend/src/components/oekaki/PostViewOekakiTombstoneCard.vue b/PinkSea.Frontend/src/components/ErrorCard.vue similarity index 84% rename from PinkSea.Frontend/src/components/oekaki/PostViewOekakiTombstoneCard.vue rename to PinkSea.Frontend/src/components/ErrorCard.vue index cbd5a9c..d7c388f 100644 --- a/PinkSea.Frontend/src/components/oekaki/PostViewOekakiTombstoneCard.vue +++ b/PinkSea.Frontend/src/components/ErrorCard.vue @@ -1,11 +1,15 @@ diff --git a/PinkSea.Frontend/src/views/PostView.vue b/PinkSea.Frontend/src/views/PostView.vue index 896a4b2..84a2f6f 100644 --- a/PinkSea.Frontend/src/views/PostView.vue +++ b/PinkSea.Frontend/src/views/PostView.vue @@ -9,8 +9,9 @@ import PostViewOekakiParentCard from '@/components/oekaki/PostViewOekakiParentCa import PostViewOekakiChildCard from '@/components/oekaki/PostViewOekakiChildCard.vue' import RespondBox from '@/components/RespondBox.vue' import Loader from '@/components/Loader.vue' -import PostViewOekakiTombstoneCard from '@/components/oekaki/PostViewOekakiTombstoneCard.vue' +import PostViewOekakiTombstoneCard from '@/components/ErrorCard.vue' import type { OekakiTombstone } from '@/models/oekaki-tombstone' +import ErrorCard from '@/components/ErrorCard.vue' const route = useRoute(); @@ -47,7 +48,7 @@ onBeforeMount(async () => {
- +
diff --git a/PinkSea.Frontend/src/views/UserView.vue b/PinkSea.Frontend/src/views/UserView.vue index 5d04fa4..3223eff 100644 --- a/PinkSea.Frontend/src/views/UserView.vue +++ b/PinkSea.Frontend/src/views/UserView.vue @@ -7,6 +7,7 @@ import { xrpc } from '@/api/atproto/client' import { useRoute } from 'vue-router' import { UserProfileTab } from '@/models/user-profile-tab' import { XRPCError } from '@atcute/client' +import ErrorCard from '@/components/ErrorCard.vue' const tabs = [ { @@ -73,7 +74,7 @@ const domainUrl = computed(() => {
- {{ profileError }} +
From 3843382f612286faec02a4ccb013466a265fecbf Mon Sep 17 00:00:00 2001 From: purifetchi <0xlunaric@gmail.com> Date: Mon, 19 May 2025 21:57:12 +0200 Subject: [PATCH 10/15] feat: add healthchecks to docker. --- Dockerfile | 4 ++++ Dockerfile.Gateway | 4 ++++ PinkSea.AtProto.Server/Xrpc/ServiceCollectionExtensions.cs | 7 +++++++ PinkSea.Gateway/Program.cs | 7 +++++++ 4 files changed, 22 insertions(+) diff --git a/Dockerfile b/Dockerfile index a26548c..77970eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,8 @@ RUN dotnet publish -c Release -o out FROM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /App COPY --from=build-env /App/out . + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl --fail http://localhost:8080/xrpc/_health || exit 1 + ENTRYPOINT ["dotnet", "PinkSea.dll"] diff --git a/Dockerfile.Gateway b/Dockerfile.Gateway index da11086..a7e65d7 100644 --- a/Dockerfile.Gateway +++ b/Dockerfile.Gateway @@ -22,4 +22,8 @@ COPY --from=build-env /App/out . RUN mkdir wwwroot COPY --from=frontend-build-env /App/dist ./wwwroot/ + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl --fail http://localhost:8080/xrpc/_health || exit 1 + ENTRYPOINT ["dotnet", "PinkSea.Gateway.dll"] diff --git a/PinkSea.AtProto.Server/Xrpc/ServiceCollectionExtensions.cs b/PinkSea.AtProto.Server/Xrpc/ServiceCollectionExtensions.cs index 11ce555..5ec36b6 100644 --- a/PinkSea.AtProto.Server/Xrpc/ServiceCollectionExtensions.cs +++ b/PinkSea.AtProto.Server/Xrpc/ServiceCollectionExtensions.cs @@ -43,6 +43,13 @@ public static IEndpointRouteBuilder UseXrpcHandler(this IEndpointRouteBuilder ro "/xrpc/{nsid}", HandleXrpc); + routeBuilder.MapGet( + "/xrpc/_health", + () => new + { + version = "PinkSea" + }); + return routeBuilder; } diff --git a/PinkSea.Gateway/Program.cs b/PinkSea.Gateway/Program.cs index 53ec450..1886b12 100644 --- a/PinkSea.Gateway/Program.cs +++ b/PinkSea.Gateway/Program.cs @@ -28,6 +28,13 @@ return Results.Text(file, contentType: "text/html"); }); +app.MapGet( + "/xrpc/_health", + () => new + { + version = "PinkSea.Gateway" + }); + app.MapFallback(async ([FromServices] MetaGeneratorService metaGenerator) => { var file = await File.ReadAllTextAsync($"./wwwroot/index.html"); From 993cf3322227ba70d9661994f1c931c148cc8fb2 Mon Sep 17 00:00:00 2001 From: purifetchi <0xlunaric@gmail.com> Date: Mon, 19 May 2025 23:41:48 +0200 Subject: [PATCH 11/15] chore: fix health checks. --- Dockerfile | 2 ++ Dockerfile.Gateway | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Dockerfile b/Dockerfile index 77970eb..594900e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /App COPY --from=build-env /App/out . +RUN apt-get update && apt-get install -y curl + HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl --fail http://localhost:8080/xrpc/_health || exit 1 diff --git a/Dockerfile.Gateway b/Dockerfile.Gateway index a7e65d7..deb88c8 100644 --- a/Dockerfile.Gateway +++ b/Dockerfile.Gateway @@ -23,6 +23,8 @@ COPY --from=build-env /App/out . RUN mkdir wwwroot COPY --from=frontend-build-env /App/dist ./wwwroot/ +RUN apt-get update && apt-get install -y curl + HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl --fail http://localhost:8080/xrpc/_health || exit 1 From 76e68b68432ce94453524fbfebc0884d15a8cd3c Mon Sep 17 00:00:00 2001 From: naomiEve <0xlunaric@gmail.com> Date: Thu, 22 May 2025 13:44:23 +0200 Subject: [PATCH 12/15] feat: prepare for profile editing. --- PinkSea.Frontend/src/views/SearchView.vue | 22 +- PinkSea/Database/Models/OekakiModel.cs | 2 +- PinkSea/Database/Models/UserLinkModel.cs | 38 +++ PinkSea/Database/Models/UserModel.cs | 32 ++ ...21_Prepare for profile editing.Designer.cs | 285 ++++++++++++++++++ ...50522112421_Prepare for profile editing.cs | 98 ++++++ .../PinkSeaDbContextModelSnapshot.cs | 67 +++- PinkSea/Services/SearchService.cs | 2 + 8 files changed, 541 insertions(+), 5 deletions(-) create mode 100644 PinkSea/Database/Models/UserLinkModel.cs create mode 100644 PinkSea/Migrations/20250522112421_Prepare for profile editing.Designer.cs create mode 100644 PinkSea/Migrations/20250522112421_Prepare for profile editing.cs diff --git a/PinkSea.Frontend/src/views/SearchView.vue b/PinkSea.Frontend/src/views/SearchView.vue index cda689e..503dd14 100644 --- a/PinkSea.Frontend/src/views/SearchView.vue +++ b/PinkSea.Frontend/src/views/SearchView.vue @@ -6,7 +6,7 @@ import TimeLine from '@/components/TimeLine.vue' import type { TagSearchResult } from '@/models/tag-search-result' import type { Author } from '@/models/author' import { xrpc } from '@/api/atproto/client' -import { useRoute } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import Intersector from '@/components/Intersector.vue' import SearchTag from '@/components/search/SearchTag.vue' import SearchProfileCard from '@/components/search/SearchProfileCard.vue' @@ -32,6 +32,7 @@ const tags = ref([]); const profiles = ref([]); const route = useRoute(); +const router = useRouter(); const applyNew = (resp: { tags?: TagSearchResult[] | null @@ -44,6 +45,17 @@ const applyNew = (resp: { } } +const open = async (id: string) => { + let url = "" + if (currentTab.value == SearchType.Tags) { + url = `/tag/${id}`; + } else if (currentTab.value == SearchType.Profiles) { + url = `/${id}`; + } + + await router.push(url); +}; + const loadMore = async () => { const opts = { params: { query: route.params.value, type: currentTab.value } }; @@ -64,13 +76,13 @@ const loadMore = async () => {
- +
- +
@@ -111,4 +123,8 @@ const loadMore = async () => { .search-result-list { padding: 10px; } + +.search-result-list > div { + cursor: pointer; +} diff --git a/PinkSea/Database/Models/OekakiModel.cs b/PinkSea/Database/Models/OekakiModel.cs index b957ded..bade112 100644 --- a/PinkSea/Database/Models/OekakiModel.cs +++ b/PinkSea/Database/Models/OekakiModel.cs @@ -78,7 +78,7 @@ public class OekakiModel /// The Bluesky crosspost record TID. /// public string? BlueskyCrosspostRecordTid { get; set; } - + /// /// The tag-oekaki relations. /// diff --git a/PinkSea/Database/Models/UserLinkModel.cs b/PinkSea/Database/Models/UserLinkModel.cs new file mode 100644 index 0000000..25adb9d --- /dev/null +++ b/PinkSea/Database/Models/UserLinkModel.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PinkSea.Database.Models; + +/// +/// A link on a user's page. +/// +public class UserLinkModel +{ + /// + /// The key of the link. + /// + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + /// + /// The DID of the user that owns this link. + /// + [ForeignKey(nameof(User))] + public required string UserDid { get; set; } + + /// + /// The user that owns this link. + /// + public required UserModel User { get; set; } + + /// + /// The name of the link. + /// + public required string Name { get; set; } + + /// + /// The URL of the link. + /// + public required string Url { get; set; } +} \ No newline at end of file diff --git a/PinkSea/Database/Models/UserModel.cs b/PinkSea/Database/Models/UserModel.cs index d7a6b32..fdf2149 100644 --- a/PinkSea/Database/Models/UserModel.cs +++ b/PinkSea/Database/Models/UserModel.cs @@ -15,6 +15,27 @@ public class UserModel [Key] public required string Did { get; set; } + /// + /// The nickname of this user. + /// + public string? Nickname { get; set; } + + /// + /// The description of this user. + /// + public string? Description { get; set; } + + /// + /// The ID of the oekaki that server as this user's avatar. + /// + [ForeignKey(nameof(Avatar))] + public string? AvatarId { get; set; } + + /// + /// The oekaki which serves as this user's avatar. + /// + public OekakiModel? Avatar { get; set; } + /// /// The cached handle of the user. May be out of date if an account event was skipped. /// @@ -34,6 +55,17 @@ public class UserModel /// When was this user created at? /// public required DateTimeOffset CreatedAt { get; set; } + + /// + /// The links this user owns. + /// + public virtual ICollection? Links { get; set; } + + /// + /// The oekaki this user has made. + /// + [InverseProperty(nameof(OekakiModel.Author))] + public virtual ICollection Oekaki { get; set; } /// /// Returns an expression that describes this user as not being deleted. diff --git a/PinkSea/Migrations/20250522112421_Prepare for profile editing.Designer.cs b/PinkSea/Migrations/20250522112421_Prepare for profile editing.Designer.cs new file mode 100644 index 0000000..9fd0002 --- /dev/null +++ b/PinkSea/Migrations/20250522112421_Prepare for profile editing.Designer.cs @@ -0,0 +1,285 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PinkSea.Database; + +#nullable disable + +namespace PinkSea.Migrations +{ + [DbContext(typeof(PinkSeaDbContext))] + [Migration("20250522112421_Prepare for profile editing")] + partial class Prepareforprofileediting + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PinkSea.Database.Models.ConfigurationModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientPrivateKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClientPublicKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SynchronizedAccountStates") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Configuration"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.OAuthStateModel", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Json") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("OAuthStates"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("AltText") + .HasColumnType("text"); + + b.Property("AuthorDid") + .IsRequired() + .HasColumnType("text"); + + b.Property("BlobCid") + .IsRequired() + .HasColumnType("text"); + + b.Property("BlueskyCrosspostRecordTid") + .HasColumnType("text"); + + b.Property("IndexedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsNsfw") + .HasColumnType("boolean"); + + b.Property("OekakiTid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("text"); + + b.Property("RecordCid") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tombstone") + .HasColumnType("boolean"); + + b.HasKey("Key"); + + b.HasIndex("ParentId"); + + b.HasIndex("Tombstone"); + + b.HasIndex("AuthorDid", "OekakiTid"); + + b.ToTable("Oekaki"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.TagModel", b => + { + b.Property("Name") + .HasColumnType("text"); + + b.HasKey("Name"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.TagOekakiRelationModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("OekakiId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TagId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OekakiId"); + + b.HasIndex("TagId"); + + b.ToTable("TagOekakiRelations"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.UserLinkModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserDid") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserDid"); + + b.ToTable("UserLinkModel"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.UserModel", b => + { + b.Property("Did") + .HasColumnType("text"); + + b.Property("AppViewBlocked") + .HasColumnType("boolean"); + + b.Property("AvatarId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Handle") + .HasColumnType("text"); + + b.Property("Nickname") + .HasColumnType("text"); + + b.Property("RepoStatus") + .HasColumnType("integer"); + + b.HasKey("Did"); + + b.HasIndex("AvatarId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b => + { + b.HasOne("PinkSea.Database.Models.UserModel", "Author") + .WithMany("Oekaki") + .HasForeignKey("AuthorDid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PinkSea.Database.Models.OekakiModel", "Parent") + .WithMany() + .HasForeignKey("ParentId"); + + b.Navigation("Author"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.TagOekakiRelationModel", b => + { + b.HasOne("PinkSea.Database.Models.OekakiModel", "Oekaki") + .WithMany("TagOekakiRelations") + .HasForeignKey("OekakiId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PinkSea.Database.Models.TagModel", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Oekaki"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.UserLinkModel", b => + { + b.HasOne("PinkSea.Database.Models.UserModel", "User") + .WithMany("Links") + .HasForeignKey("UserDid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.UserModel", b => + { + b.HasOne("PinkSea.Database.Models.OekakiModel", "Avatar") + .WithMany() + .HasForeignKey("AvatarId"); + + b.Navigation("Avatar"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b => + { + b.Navigation("TagOekakiRelations"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.UserModel", b => + { + b.Navigation("Links"); + + b.Navigation("Oekaki"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PinkSea/Migrations/20250522112421_Prepare for profile editing.cs b/PinkSea/Migrations/20250522112421_Prepare for profile editing.cs new file mode 100644 index 0000000..1d51590 --- /dev/null +++ b/PinkSea/Migrations/20250522112421_Prepare for profile editing.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace PinkSea.Migrations +{ + /// + public partial class Prepareforprofileediting : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AvatarId", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Description", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Nickname", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "UserLinkModel", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserDid = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Url = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserLinkModel", x => x.Id); + table.ForeignKey( + name: "FK_UserLinkModel_Users_UserDid", + column: x => x.UserDid, + principalTable: "Users", + principalColumn: "Did", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_AvatarId", + table: "Users", + column: "AvatarId"); + + migrationBuilder.CreateIndex( + name: "IX_UserLinkModel_UserDid", + table: "UserLinkModel", + column: "UserDid"); + + migrationBuilder.AddForeignKey( + name: "FK_Users_Oekaki_AvatarId", + table: "Users", + column: "AvatarId", + principalTable: "Oekaki", + principalColumn: "Key"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Users_Oekaki_AvatarId", + table: "Users"); + + migrationBuilder.DropTable( + name: "UserLinkModel"); + + migrationBuilder.DropIndex( + name: "IX_Users_AvatarId", + table: "Users"); + + migrationBuilder.DropColumn( + name: "AvatarId", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Description", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Nickname", + table: "Users"); + } + } +} diff --git a/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs b/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs index 753d80d..c6efeb5 100644 --- a/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs +++ b/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs @@ -149,6 +149,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("TagOekakiRelations"); }); + modelBuilder.Entity("PinkSea.Database.Models.UserLinkModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserDid") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserDid"); + + b.ToTable("UserLinkModel"); + }); + modelBuilder.Entity("PinkSea.Database.Models.UserModel", b => { b.Property("Did") @@ -157,24 +184,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AppViewBlocked") .HasColumnType("boolean"); + b.Property("AvatarId") + .HasColumnType("text"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Description") + .HasColumnType("text"); + b.Property("Handle") .HasColumnType("text"); + b.Property("Nickname") + .HasColumnType("text"); + b.Property("RepoStatus") .HasColumnType("integer"); b.HasKey("Did"); + b.HasIndex("AvatarId"); + b.ToTable("Users"); }); modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b => { b.HasOne("PinkSea.Database.Models.UserModel", "Author") - .WithMany() + .WithMany("Oekaki") .HasForeignKey("AuthorDid") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -207,10 +245,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Tag"); }); + modelBuilder.Entity("PinkSea.Database.Models.UserLinkModel", b => + { + b.HasOne("PinkSea.Database.Models.UserModel", "User") + .WithMany("Links") + .HasForeignKey("UserDid") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PinkSea.Database.Models.UserModel", b => + { + b.HasOne("PinkSea.Database.Models.OekakiModel", "Avatar") + .WithMany() + .HasForeignKey("AvatarId"); + + b.Navigation("Avatar"); + }); + modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b => { b.Navigation("TagOekakiRelations"); }); + + modelBuilder.Entity("PinkSea.Database.Models.UserModel", b => + { + b.Navigation("Links"); + + b.Navigation("Oekaki"); + }); #pragma warning restore 612, 618 } } diff --git a/PinkSea/Services/SearchService.cs b/PinkSea/Services/SearchService.cs index 3615345..2ffe71d 100644 --- a/PinkSea/Services/SearchService.cs +++ b/PinkSea/Services/SearchService.cs @@ -22,6 +22,7 @@ public async Task> SearchPosts(string query, int limit, DateTi .Where(o => !o.Tombstone) .Where(o => o.AltText!.ToLower().Contains(query.ToLower()) || o.TagOekakiRelations!.Any(to => to.TagId.ToLower().Contains(query.ToLower()))) // TODO: Author + .Distinct() .OrderByDescending(o => o.IndexedAt) .Where(o => o.IndexedAt < since) .Take(limit) @@ -36,6 +37,7 @@ public async Task> SearchTags(string query, int limit, Dat .Where(t => t.Name.ToLower().Contains(query.ToLower())) .Join(dbContext.TagOekakiRelations, t => t.Name, to => to.TagId, (t, to) => new { t, to }) .Join(dbContext.Oekaki, c => c.to.OekakiId, o => o.Key, (c, o) => new { c.t, c.to, o }) + .Distinct() .GroupBy(c => c.t.Name) .Take(limit) .Select(c => new From 4b3a26392a4cb60a013d5422d4a8958b928da05d Mon Sep 17 00:00:00 2001 From: purifetchi <0xlunaric@gmail.com> Date: Fri, 27 Jun 2025 17:23:58 +0200 Subject: [PATCH 13/15] fix: unbreak enter on search box. --- PinkSea.Frontend/src/layouts/PanelLayout.vue | 24 +++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/PinkSea.Frontend/src/layouts/PanelLayout.vue b/PinkSea.Frontend/src/layouts/PanelLayout.vue index dceb294..cee9732 100644 --- a/PinkSea.Frontend/src/layouts/PanelLayout.vue +++ b/PinkSea.Frontend/src/layouts/PanelLayout.vue @@ -42,7 +42,8 @@ onBeforeMount(async () => { params: {}, headers: { "Authorization": `Bearer ${persistedStore.token}` - }}); + } + }); identityStore.did = data.did; identityStore.handle = data.handle; @@ -71,7 +72,8 @@ const logout = async () => { data: {}, headers: { "Authorization": `Bearer ${persistedStore.token}` - }}); + } + }); } finally { persistedStore.token = null; } @@ -107,8 +109,10 @@ const getCreateSomethingButtonName = computed(() => {
{{ $t("menu.search") }}
- - + +

@@ -118,13 +122,15 @@ const getCreateSomethingButtonName = computed(() => {
{{ $t("menu.greeting", { name: identityStore.handle }) }}
    -
  • {{ $t("menu.my_oekaki") }}
  • +
  • {{ + $t("menu.my_oekaki") }}
  • {{ $t("menu.recent") }}
  • {{ $t("menu.settings") }}
  • {{ $t("menu.logout") }}
- +
@@ -212,12 +218,14 @@ h1 { padding-bottom: 6px; } -h1, .title h2 { +h1, +.title h2 { padding-right: 4px; padding-left: 4px; } -.title, .aside-box { +.title, +.aside-box { box-shadow: 0px 1px #FFB6C1, 0px -1px #FFB6C1; } From 29bf8a8c5a42f8b91d5da69462e6bb63c3f0aeb7 Mon Sep 17 00:00:00 2001 From: purifetchi <0xlunaric@gmail.com> Date: Fri, 27 Jun 2025 17:28:11 +0200 Subject: [PATCH 14/15] chore: update translations from weblate. --- .../src/intl/translations/fr.json | 262 +++++++++--------- .../src/intl/translations/it.json | 188 +++++++++---- .../src/intl/translations/sv.json | 119 ++++---- 3 files changed, 324 insertions(+), 245 deletions(-) diff --git a/PinkSea.Frontend/src/intl/translations/fr.json b/PinkSea.Frontend/src/intl/translations/fr.json index 6f3a8e3..b5da309 100644 --- a/PinkSea.Frontend/src/intl/translations/fr.json +++ b/PinkSea.Frontend/src/intl/translations/fr.json @@ -1,133 +1,133 @@ { - "sidebar": { - "title": "PinkSea", - "tag": "oekaki BBS", - "shinolabs": "Un projet des laboratoires Shinonome" - }, - "menu": { - "greeting": "Salut @{{name}}!", - "invitation": "Connecte toi pour dessiner!", - "input_placeholder": "@your.cool.tld", - "atp_login": "@ Connexion", - "my_oekaki": "Mon oekaki", - "recent": "Récent", - "settings": "Paramètres", - "logout": "Déconnexion", - "create_something": "Créer quelque chose", - "search": "Rechercher", - "search_placeholder": "Recherche d'un tag", - "password": "Mot de passe (Optionnel)", - "oauth2_info": "Si tu laisse le champ de mot de passe vide, PinkSea va utiliser OAuth2 pour se connecter à ton PDS. C'est généralement plus sécurisé qu'une connexion par mot de passe.", - "search_go": "Aller" - }, - "breadcrumb": { - "recent": "récent", - "painter": "dessinateur", - "settings": "tes paramètres", - "user_profile": "profil de {{handle}}", - "user_post": "publications de {{handle}}", - "tagged": "publications taguées #{{tag}}" - }, - "timeline": { - "by_before_handle": "Par ", - "by_after_handle": "", - "nothing_here": "Rien ici pour l'instant... (╥﹏╥)" - }, - "post": { - "response_from_before_handle": "Réponse de ", - "response_from_after_handle": "", - "response_from_at_date": " à ", - "this_post_no_longer_exists": "Ce post n'existe plus." - }, - "response_box": { - "login_to_respond": "Connecte toi pour répondre!", - "click_to_respond": "Clique pour ouvrir le dessinateur", - "open_painter": "Ouvrir le dessinateur", - "reply": "Répondre!", - "cancel": "Annuler" - }, - "settings": { - "category_general": "général", - "general_language": "Langage", - "category_sensitive": "Médias sensibles", - "sensitive_blur_nsfw": "Flouter les publications NSFW", - "sensitive_hide_nsfw": "Ne pas afficher les publications NSFW" - }, - "painter": { - "do_you_want_to_restore": "La dernière tentative de publication a échoué et ton image a été sauvegardée. Veux-tu la restaurer?", - "could_not_send_post": "Il y a eu un problème lors de l'envoi de la publication. Veuillez réessayer plus tard. Votre publication a été sauvegardé dans votre navigateur.", - "add_a_description": "Ajoute une description!", - "tag": "Tag", - "crosspost_to_bluesky": "Cross-post sur Bluesky", - "upload_description": "Description", - "upload_tags": "Tags", - "upload_confirm": "Confirmer", - "hint_description": "Rajouter une courte description permet de donner du contexte à votre dessin. Optionnel.", - "hint_confirm": "Une fois que vous êtes prêts, cliquez sur le bouton au dessus pour publier votre image!", - "upload_social": "Accessibilité", - "hint_xpost": "Si coché, nous allons créer automatiquement un post pour vous sur Bluesky avec l'image et un lien vers PinkSea.", - "upload": "Publier!", - "hint_tags": "Donne à ton post jusqu'à cinq tags pour qu'il puisse être découvert facilement ! Par exemple: personnages (koiwai_yotsuba), licences (yotsubato! /oc) ou information generale (portrait). Optionnel.", - "hint_nsfw": "Coche s'il te plaît si ton post contient du contenu pour adulte tel que de la nudité ou des éléments suggestifs." - }, - "profile": { - "bluesky_profile": "Profil Bluesky", - "domain": "Site web", - "posts_tab": "Posts", - "replies_tab": "Réponses" - }, - "tegakijs": { - "badDimensions": "Dimensions invalides.", - "confirmDelLayers": "Supprimer les calques sélectionnés?", - "confirmMergeLayers": "Fusionner les calques sélectionnés?", - "tooManyLayers": "Limite de calque atteinte.", - "noActiveLayer": "Pas de calque actif.", - "hiddenActiveLayer": "Le calque actif n'est pas visible.", - "blur": "Flou", - "eraser": "Gomme", - "bucket": "Seau", - "pause": "Pause", - "slower": "Ralentir", - "faster": "Accélérer", - "preserveAlpha": "Conserver L'Alpha", - "pen": "Stylo", - "pencil": "Crayon", - "airbrush": "Aérographe", - "pipette": "Pipette", - "play": "Jouer", - "loadingReplay": "Chargement du replay…", - "recordingEnabled": "Enregistrer un replay", - "rewind": "Rembobiner", - "tone": "Tone", - "promptWidth": "Largeur de la toile en pixels", - "promptHeight": "Hauteur de la toile en pixels", - "errorLoadImage": "L'image n'a pas pu être chargée.", - "confirmCancel": "Es-tu sûre? Ton travail sera perdu.", - "size": "Taille", - "alpha": "Opacité", - "zoom": "Zoom", - "layers": "Calques", - "addLayer": "Ajouter un calque", - "moveLayerUp": "Monter", - "moveLayerDown": "Descendre", - "newCanvas": "Nouveau", - "open": "Ouvrir", - "save": "Sauvegarder", - "export": "Exporter", - "redo": "Rétablir", - "undo": "Annuler", - "close": "Fermer", - "pressure": "Pression", - "toggleVisibility": "Activer/désactiver la visibilité", - "switchPalette": "Change de palette", - "paletteSlotReplace": "Clic droit pour remplacer avec la couleur sélectionnée", - "finish": "Finir", - "delLayers": "Supprimer les calques", - "mergeLayers": "Fusionner les calques", - "layer": "Calque", - "confirmChangeCanvas": "Es-tu sûre? Changer la toile va effacer tout les calques et l'historique et désactiver l'enregistrement du replay.", - "color": "Couleur", - "errorLoadReplay": "Le replay n'a pas pu être chargé: ", - "saveAs": "Sauvegarder Sous" - } + "sidebar": { + "title": "PinkSea", + "tag": "oekaki BBS", + "shinolabs": "Un projet des laboratoires Shinonome" + }, + "menu": { + "greeting": "Salut @{{name}}!", + "invitation": "Connecte toi pour dessiner!", + "input_placeholder": "@your.cool.tld", + "atp_login": "@ Connexion", + "my_oekaki": "Mon oekaki", + "recent": "Récent", + "settings": "Paramètres", + "logout": "Déconnexion", + "create_something": "Créer quelque chose", + "search": "Rechercher", + "search_placeholder": "Recherche d'un tag", + "password": "Mot de passe (Optionnel)", + "oauth2_info": "Si tu laisse le champ de mot de passe vide, PinkSea va utiliser OAuth2 pour se connecter à ton PDS. C'est généralement plus sécurisé qu'une connexion par mot de passe.", + "search_go": "Aller" + }, + "breadcrumb": { + "recent": "récent", + "painter": "dessinateur", + "settings": "tes paramètres", + "user_profile": "profil de {{handle}}", + "user_post": "publications de {{handle}}", + "tagged": "publications taguées #{{tag}}" + }, + "timeline": { + "by_before_handle": "Par ", + "by_after_handle": "", + "nothing_here": "Rien ici pour l'instant... (╥﹏╥)" + }, + "post": { + "response_from_before_handle": "Réponse de ", + "response_from_after_handle": "", + "response_from_at_date": " à ", + "this_post_no_longer_exists": "Ce post n'existe plus." + }, + "response_box": { + "login_to_respond": "Connecte toi pour répondre!", + "click_to_respond": "Clique pour ouvrir le dessinateur", + "open_painter": "Ouvrir le dessinateur", + "reply": "Répondre!", + "cancel": "Annuler" + }, + "settings": { + "category_general": "général", + "general_language": "Langage", + "category_sensitive": "Médias sensibles", + "sensitive_blur_nsfw": "Flouter les publications NSFW", + "sensitive_hide_nsfw": "Ne pas afficher les publications NSFW" + }, + "painter": { + "do_you_want_to_restore": "La dernière tentative de publication a échoué et ton image a été sauvegardée. Veux-tu la restaurer?", + "could_not_send_post": "Il y a eu un problème lors de l'envoi de la publication. Veuillez réessayer plus tard. Votre publication a été sauvegardé dans votre navigateur.", + "add_a_description": "Ajoute une description!", + "tag": "Tag", + "crosspost_to_bluesky": "Cross-post sur Bluesky", + "upload_description": "Description", + "upload_tags": "Tags", + "upload_confirm": "Confirmer", + "hint_description": "Rajouter une courte description permet de donner du contexte à votre dessin. Optionnel.", + "hint_confirm": "Une fois que vous êtes prêts, cliquez sur le bouton au dessus pour publier votre image!", + "upload_social": "Accessibilité", + "hint_xpost": "Si coché, nous allons créer automatiquement un post pour vous sur Bluesky avec l'image et un lien vers PinkSea.", + "upload": "Publier!", + "hint_tags": "Donne à ton post jusqu'à cinq tags pour qu'il puisse être découvert facilement ! Par exemple: personnages (koiwai_yotsuba), licences (yotsubato! /oc) ou information generale (portrait). Optionnel.", + "hint_nsfw": "Coche s'il te plaît si ton post contient du contenu pour adulte tel que de la nudité ou des éléments suggestifs." + }, + "profile": { + "bluesky_profile": "Profil Bluesky", + "domain": "Site web", + "posts_tab": "Posts", + "replies_tab": "Réponses" + }, + "tegakijs": { + "badDimensions": "Dimensions invalides.", + "confirmDelLayers": "Supprimer les calques sélectionnés?", + "confirmMergeLayers": "Fusionner les calques sélectionnés?", + "tooManyLayers": "Limite de calque atteinte.", + "noActiveLayer": "Pas de calque actif.", + "hiddenActiveLayer": "Le calque actif n'est pas visible.", + "blur": "Flou", + "eraser": "Gomme", + "bucket": "Seau", + "pause": "Pause", + "slower": "Ralentir", + "faster": "Accélérer", + "preserveAlpha": "Conserver L'Alpha", + "pen": "Stylo", + "pencil": "Crayon", + "airbrush": "Aérographe", + "pipette": "Pipette", + "play": "Jouer", + "loadingReplay": "Chargement du replay…", + "recordingEnabled": "Enregistrer un replay", + "rewind": "Rembobiner", + "tone": "Tone", + "promptWidth": "Largeur de la toile en pixels", + "promptHeight": "Hauteur de la toile en pixels", + "errorLoadImage": "L'image n'a pas pu être chargée.", + "confirmCancel": "Es-tu sûre? Ton travail sera perdu.", + "size": "Taille", + "alpha": "Opacité", + "zoom": "Zoom", + "layers": "Calques", + "addLayer": "Ajouter un calque", + "moveLayerUp": "Monter", + "moveLayerDown": "Descendre", + "newCanvas": "Nouveau", + "open": "Ouvrir", + "save": "Sauvegarder", + "export": "Exporter", + "redo": "Rétablir", + "undo": "Annuler", + "close": "Fermer", + "pressure": "Pression", + "toggleVisibility": "Activer/désactiver la visibilité", + "switchPalette": "Change de palette", + "paletteSlotReplace": "Clic droit pour remplacer avec la couleur sélectionnée", + "finish": "Finir", + "delLayers": "Supprimer les calques", + "mergeLayers": "Fusionner les calques", + "layer": "Calque", + "confirmChangeCanvas": "Es-tu sûre? Changer la toile va effacer tout les calques et l'historique et désactiver l'enregistrement du replay.", + "color": "Couleur", + "errorLoadReplay": "Le replay n'a pas pu être chargé: ", + "saveAs": "Sauvegarder Sous" + } } diff --git a/PinkSea.Frontend/src/intl/translations/it.json b/PinkSea.Frontend/src/intl/translations/it.json index 52a1886..f0c6fbd 100644 --- a/PinkSea.Frontend/src/intl/translations/it.json +++ b/PinkSea.Frontend/src/intl/translations/it.json @@ -1,56 +1,136 @@ { - "sidebar": { - "title": "PinkSea", - "tag": "oekaki BBS", - "shinolabs": "Una produzione Shinonome Laboratories" - }, - "menu": { - "greeting": "Salve @{{name}}!", - "invitation": "Accedi per iniziare a creare!", - "input_placeholder": "@il-tuo-handle.bsky.social", - "atp_login": "@ Accedi", - "my_oekaki": "Il Mio Oekaki", - "recent": "Recente", - "settings": "Impostazioni", - "logout": "Disconnetti", - "create_something": "Crea Qualcosa" - }, - "breadcrumb": { - "recent": "recente", - "painter": "bozzetto", - "settings": "le tue impostazioni", - "user_profile": "profilo di {{handle}}", - "user_post": "pubblicazione di {{handle}}", - "tagged": "pubblicazioni taggate #{{tag}}" - }, - "timeline": { - "by_before_handle": "Fatto Da ", - "by_after_handle": "" - }, - "post": { - "response_from_before_handle": "Risposta Fatta Da ", - "response_from_after_handle": "", - "response_from_at_date": " alle " - }, - "response_box": { - "login_to_respond": "Accedi per rispondere!", - "click_to_respond": "Clicca per aprire il pannello da disegno", - "open_painter": "Apri Bozzetto", - "reply": "Rispondi!" - }, - "settings": { - "category_general": "generale", - "general_language": "Lingua", - - "category_sensitive": "materiale sensibile", - "sensitive_blur_nsfw": "Sfoca pubblicazioni NSFW", - "sensitive_hide_nsfw": "Non mostrare pubblicazioni NSFW" - }, - "painter": { - "do_you_want_to_restore": "L'ultimo caricamento ha avuto un' errore e la tua immagine è stata salvata. Vuoi ripristinarlo?", - "could_not_send_post": "C'è stato un problema con il caricamento della tua pubblicazione. Riprova dopo, perfavore. La tua pubblicazione è stata salvata nel tuo browser." - }, - "profile": { - "bluesky_profile": "Profilo Bluesky" - } + "sidebar": { + "title": "PinkSea", + "tag": "oekaki BBS", + "shinolabs": "un progetto shinonome laboratories" + }, + "menu": { + "greeting": "Ciao @{{name}}!", + "invitation": "Accedi per iniziare a creare!", + "input_placeholder": "@alice.bsky.social", + "atp_login": "@ Accedi", + "my_oekaki": "Il mio oekaki", + "recent": "Recenti", + "settings": "Impostazioni", + "logout": "Esci", + "create_something": "Crea qualcosa", + "search_placeholder": "Cerca un tag", + "search": "Cerca", + "search_go": "Vai", + "password": "Password (Opzionale)", + "oauth2_info": "Se lasci il campo password vuoto, PinkSea userà OAuth2 per connettersi al tuo PDS. In genere è più sicuro di accedere tramite password." + }, + "breadcrumb": { + "recent": "recenti", + "painter": "bozzetto", + "settings": "le tue impostazioni", + "user_profile": "profilo di {{handle}}", + "user_post": "post di {{handle}}", + "tagged": "post taggati #{{tag}}" + }, + "timeline": { + "by_before_handle": "Di ", + "by_after_handle": "", + "nothing_here": "Non c'è nulla qui per ora... (╥﹏╥)" + }, + "post": { + "response_from_before_handle": "Risposta da ", + "response_from_after_handle": "", + "response_from_at_date": " il ", + "this_post_no_longer_exists": "Questo post non esiste più." + }, + "response_box": { + "login_to_respond": "Accedi per rispondere!", + "click_to_respond": "Clicca per aprire il pannello di disegno", + "open_painter": "Apri bozzetto", + "reply": "Rispondi!", + "cancel": "Annulla" + }, + "settings": { + "category_general": "generale", + "general_language": "Lingua", + "category_sensitive": "materiale sensibile", + "sensitive_blur_nsfw": "Sfoca post NSFW", + "sensitive_hide_nsfw": "Non mostrare post NSFW" + }, + "painter": { + "do_you_want_to_restore": "L'ultimo caricamento è risultato in un errore e la tua immagine è stata salvata. Vuoi ripristinarla?", + "could_not_send_post": "C'è stato un problema con il caricamento del tuo post. Riprova dopo, per favore. Il tuo post è stato salvato nel tuo browser.", + "hint_description": "Allegare una breve descrizione aiuta a dare contesto al tuo disegno. Opzionale.", + "hint_tags": "Dai fino a cinque tag al tuo post per aiutare gli altri a scoprirlo! Per esempio: personaggi (koiwai_yotsuba), copyright (yotsubato! / oc) o informazioni generali (ritratto). Opzionale.", + "add_a_description": "Aggiungi una descrizione!", + "tag": "Tag", + "crosspost_to_bluesky": "Cross-posta su Bluesky", + "upload": "Carica!", + "upload_description": "Descrizione", + "upload_tags": "Tag", + "upload_social": "Sociale", + "upload_confirm": "Conferma", + "hint_nsfw": "Per piacere controlla se il tuo post contiene contenuti da adulti come nudo o temi altamente suggestivi.", + "hint_xpost": "Se spuntato, creeremo automaticamente un post su Bluesky per te con allegati l'immagine e un link a PinkSea.", + "hint_confirm": "Quando sei pronto, clicca sul bottone sopra per pubblicare la tua immagine!" + }, + "profile": { + "bluesky_profile": "Profilo Bluesky", + "domain": "Sito web", + "posts_tab": "Post", + "replies_tab": "Risposte" + }, + "tegakijs": { + "confirmCancel": "Sei sicuro? Il tuo lavoro andrà perso.", + "paletteSlotReplace": "Click destro per sostituire con il colore attuale", + "badDimensions": "Dimensioni non valide.", + "promptWidth": "Larghezza della tela in pixel", + "promptHeight": "Altezza della tela in pixel", + "confirmDelLayers": "Cancella layer selezionati?", + "confirmMergeLayers": "Unisci layer selezionati?", + "tooManyLayers": "Limite di layer raggiunto.", + "noActiveLayer": "Nessun layer attivo.", + "hiddenActiveLayer": "Il layer attivo non è visibile.", + "color": "Colore", + "size": "Dimensione", + "alpha": "Opacità", + "flow": "Flusso", + "zoom": "Zoom", + "layers": "Layer", + "switchPalette": "Cambia tavolozza", + "layer": "Layer", + "addLayer": "Aggiungi layer", + "gapless": "Ininterrotto", + "mergeLayers": "Unisci layer", + "moveLayerUp": "Sposta sopra", + "moveLayerDown": "Sposta sotto", + "newCanvas": "Nuovo", + "open": "Apri", + "save": "Salva", + "saveAs": "Salva Come", + "export": "Esporta", + "undo": "Annulla", + "redo": "Ripeti", + "close": "Chiudi", + "finish": "Termina", + "pressure": "Pressione", + "preserveAlpha": "Mantieni Alfa", + "pen": "Penna", + "pencil": "Matita", + "airbrush": "Aerografo", + "pipette": "Pipetta", + "blur": "Sfoca", + "eraser": "Gomma", + "tone": "Tonalità", + "play": "Riproduci", + "pause": "Pausa", + "rewind": "Riavvolgi", + "slower": "Più lento", + "faster": "Più veloce", + "errorLoadReplay": "Non è stato possibile caricare il replay: ", + "loadingReplay": "Caricamento replay…", + "confirmChangeCanvas": "Sei sicuro? Cambiare la tela rimuoverà tutti i layer e lo storico e disattiverà la registrazione del replay.", + "delLayers": "Elimina layer", + "recordingEnabled": "Registrazione replay", + "toggleVisibility": "Inverti visibilità", + "errorLoadImage": "Non è stato possibile caricare l'immagine.", + "bucket": "Secchiello", + "tip": "Punta" + } } diff --git a/PinkSea.Frontend/src/intl/translations/sv.json b/PinkSea.Frontend/src/intl/translations/sv.json index 41d2fdb..8c77a6f 100644 --- a/PinkSea.Frontend/src/intl/translations/sv.json +++ b/PinkSea.Frontend/src/intl/translations/sv.json @@ -1,62 +1,61 @@ { - "sidebar": { - "title": "PinkSea", - "tag": "oekaki BBS", - "shinolabs": "ett shinonome laboratories project" - }, - "menu": { - "greeting": "Hej @{{name}}!", - "invitation": "Logga in och börja skapa", - "input_placeholder": "@alice.bsky.social", - "atp_login": "@ Login", - "my_oekaki": "Min oekaki", - "recent": "Senaste", - "settings": "Inställningar", - "logout": "Logga ut", - "create_something": "Skapa något" - }, - "breadcrumb": { - "recent": "senaste", - "painter": "tecknare", - "settings": "dina inställningar", - "user_profile": "{{handle}}'s profil", - "user_post": "{{handle}}'s inlägg", - "tagged": "inlägg märkta #{{tag}}" - }, - "timeline": { - "by_before_handle": "Av ", - "by_after_handle": "" - }, - "post": { - "response_from_before_handle": "Svar från ", - "response_from_after_handle": "", - "response_from_at_date": " vid " - }, - "response_box": { - "login_to_respond": "Logga in för att svara!", - "click_to_respond": "Klicka för att öppna ritpanelen", - "open_painter": "Öppna tecknare", - "reply": "Svara!", - "cancel": "Avbryt" - }, - "settings": { - "category_general": "allmänt", - "general_language": "Språk", - - "category_sensitive": "sensitive media", - "sensitive_blur_nsfw": "Gör NSFW inlägg suddiga", - "sensitive_hide_nsfw": "Visa inte NSFW inlägg" - }, - "painter": { - "do_you_want_to_restore": "Den senaste uppladdningen har gett ett felmeddelande och din bild har sparats. Vill du återställa det?", - "could_not_send_post": "Det uppstod problem vid publiceringen av inlägget. Försök igen senare. Ditt inlägg har sparats i din webbläsare.", - "add_a_description": "Lägg till beskrivning!", - "tag": "Tagg", - "crosspost_to_bluesky": "Kors-publicera till BlueSky", - "upload": "Ladda up!" - }, - "profile": { - "bluesky_profile": "Bluesky profil", - "domain": "Hemsida" - } + "sidebar": { + "title": "PinkSea", + "tag": "oekaki BBS", + "shinolabs": "ett shinonome laboratories project" + }, + "menu": { + "greeting": "Hej @{{name}}!", + "invitation": "Logga in och börja skapa!", + "input_placeholder": "@alice.bsky.social", + "atp_login": "@ Login", + "my_oekaki": "Min oekaki", + "recent": "Senaste", + "settings": "Inställningar", + "logout": "Logga ut", + "create_something": "Skapa något" + }, + "breadcrumb": { + "recent": "senaste", + "painter": "tecknare", + "settings": "dina inställningar", + "user_profile": "{{handle}}'s profil", + "user_post": "{{handle}}'s inlägg", + "tagged": "inlägg märkta #{{tag}}" + }, + "timeline": { + "by_before_handle": "Av ", + "by_after_handle": "" + }, + "post": { + "response_from_before_handle": "Svar från ", + "response_from_after_handle": "", + "response_from_at_date": " vid " + }, + "response_box": { + "login_to_respond": "Logga in för att svara!", + "click_to_respond": "Klicka för att öppna ritpanelen", + "open_painter": "Öppna tecknare", + "reply": "Svara!", + "cancel": "Avbryt" + }, + "settings": { + "category_general": "allmänt", + "general_language": "Språk", + "category_sensitive": "sensitive media", + "sensitive_blur_nsfw": "Gör NSFW inlägg suddiga", + "sensitive_hide_nsfw": "Visa inte NSFW inlägg" + }, + "painter": { + "do_you_want_to_restore": "Den senaste uppladdningen har gett ett felmeddelande och din bild har sparats. Vill du återställa det?", + "could_not_send_post": "Det uppstod problem vid publiceringen av inlägget. Försök igen senare. Ditt inlägg har sparats i din webbläsare.", + "add_a_description": "Lägg till beskrivning!", + "tag": "Tagg", + "crosspost_to_bluesky": "Kors-publicera till BlueSky", + "upload": "Ladda up!" + }, + "profile": { + "bluesky_profile": "Bluesky profil", + "domain": "Hemsida" + } } From aa38cb2bc1aa1a14878fcfffc771ca3420120342 Mon Sep 17 00:00:00 2001 From: purifetchi <0xlunaric@gmail.com> Date: Fri, 27 Jun 2025 17:52:12 +0200 Subject: [PATCH 15/15] fix: improve counting. --- PinkSea.Frontend/src/views/SearchView.vue | 35 +++++++++++++++++++---- PinkSea/Services/SearchService.cs | 12 ++++---- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/PinkSea.Frontend/src/views/SearchView.vue b/PinkSea.Frontend/src/views/SearchView.vue index 503dd14..8445351 100644 --- a/PinkSea.Frontend/src/views/SearchView.vue +++ b/PinkSea.Frontend/src/views/SearchView.vue @@ -64,26 +64,44 @@ const loadMore = async () => { applyNew(data); }; + +const setTab = (tab: SearchType) => { + currentTab.value = tab; + + if (currentTab.value == SearchType.Tags) { + tags.value = []; + } else if (currentTab.value == SearchType.Profiles) { + profiles.value = []; + } +};