Global "Search Anything" typeahead dropdown in navbar #91

Merged
REDCODE merged 52 commits from feature/global-search-dropdown into develop 2026-07-13 15:18:42 +00:00
35 changed files with 2636 additions and 645 deletions

View File

@@ -24,11 +24,12 @@ dotnet build # compiles SCSS automatically
- Aggressive gradient mixins in `Styles/_gradients.scss`
- Works in Docker — MSBuild task runs during `dotnet build`/`dotnet publish`, no hosted service
## Projects (4 in solution)
## Projects (5 in solution)
| Project | Role | Entrypoint |
|---|---|---|
| **Butter** | Shared class library (DTOs, enums, MIME types) | — |
| **Lactose.Analyzers** | Roslyn analyzer — enforces `DateTime.UtcNow` (not `DateTime.Now`) as error MS001 | — |
| **Lactose** | ASP.NET Core Web API — controllers, EF Core, repos, background jobs | `Lactose/Program.cs` |
| **MilkStream** | Blazor WASM host — serves WASM files + dynamic `/appsettings.json` | `MilkStream/Program.cs` |
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services, shared UI components (ModalFrame, EmptyState) | `MilkStream.Client/Program.cs` |
@@ -53,6 +54,8 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
- **EF Core Tools v8.0.15 vs EF Core Design v10.0.9:** Version mismatch may cause `dotnet ef` CLI quirks.
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (default) for login, and one named `"MilkstreamClient"` which has `JwtTokenRefresher` as a `DelegatingHandler`. The handler transparently refreshes expired tokens pre-flight and retries once on 401 — authenticated services must **never** handle token refreshes manually. Do not add `SendWithRefreshAsync` or similar wrappers; rely solely on the named client.
- **HTTPS redirection is commented out** in Lactose — API does not enforce HTTPS.
- **`SharedWith.Any()` EF Core translation:** Cannot be translated to SQL through many-to-many join tables (`AlbumAsset`, `AssetUser`). When visibility filtering includes `SharedWith.Any(u => u.Id == userId)`, you must materialize records first with `.Include().ThenInclude()` chains, then filter client-side in C#. The two-pass pagination approach preserves page-limited loading — see `PersonRepository.SearchQuery` and `AlbumRepository.SearchQuery` for the pattern.
- **DateTime analyzer:** `Lactose.Analyzers` treats `DateTime.Now` as error MS001. Always use `DateTime.UtcNow`. All timestamp columns use `timestamp with time zone`.
## XML docs enforced
@@ -65,6 +68,7 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
- **Repository `Save()` must be called explicitly** after insert/update/delete
- **Enum prefix `E`** (e.g. `EAccessLevel`, `EAssetType`, `EJobStatus`)
- **JWT access token**: 10 min, refresh token: 60 min (constants in `LactoseAuthService.cs`)
- **Pagination is zero-based** everywhere — `Page = 0` is the first page. All repositories use `Skip(page * pageSize)`. All controllers validate `Page < 0` (reject negative) and `PageSize < 1 || PageSize > 250` (clamp 1250).
- **Conventional Commits** enforced via `cliff.toml`; changelog generated with `git-cliff`
- **Granular commits** — commit each logical change separately (e.g., DTO change, controller logic, UI component) with a descriptive commit message
- **Reuse API endpoints** — prefer adding optional query parameters to existing endpoints over creating new routes. New endpoints are a last resort when the existing ones fundamentally cannot serve the need.
@@ -90,8 +94,25 @@ dotnet ef migrations add <Name> --project Lactose
dotnet ef database update --project Lactose
```
**Database indexes for performance** should be defined using the **fluent API** in `LactoseDbContext.OnModelCreating` via `HasIndex().HasFilter()`. Do not use the `[Index]` data annotation — the `Filter` parameter is not available in the current package configuration (EF Core Tools v8.0.15 vs Design v10.0.9 mismatch). Example:
```csharp
modelBuilder.Entity<Asset>().HasIndex(e => new { e.IsPubliclyShared, e.OwnerId })
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
```
- **Trigram GIN indexes** on `People.Name` and `Albums.Title` use `HasMethod("gin")` with `HasOperators("gin_trgm_ops")` for case-insensitive `ILike` search.
- **pgvector and pg_trgm** extensions are enabled in `OnModelCreating` via `HasPostgresExtension`.
## Tests
No test project found in solution.
No unit/integration test project exists. API endpoint testing is done via **REST Client `.http` files**:
- **`Lactose/WepApiTest.http`** — 67 numbered tests covering all API endpoints across 10 sections (Auth → User CRUD → Person → Album → Tag → Asset → Stats → Settings → Cleanup). Tests verify authorization at every level (anonymous, user, curator, admin). Self-contained — all test data is created at runtime and cleaned up.
- **`Lactose/http-client.env.json`** — provides pre-filled variable values (`userName`, `curatorName`, etc.) for the REST Client extension.
- Open in VS Code/Rider with the REST Client extension and run individual tests or the full suite.
- Server must be running locally (default `http://localhost:5162`).
- Paginated list tests (people, albums, users) use `?page=0&pageSize=5` — change these carefully.

View File

@@ -4,6 +4,12 @@ namespace Butter.Dtos;
/// Represents pagination parameters for a query.
/// </summary>
public class PagedParametersDto {
/// <summary>
/// Contains the global constant for max pages.
/// </summary>
public const int MaxPageSize = 250;
/// <summary>
/// Gets or sets the page number (zero-based).
/// </summary>

View File

@@ -60,6 +60,7 @@ public class LactoseDbContext : DbContext {
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.HasPostgresExtension("vector");
modelBuilder.HasPostgresExtension("pg_trgm");
//Album Relationships
modelBuilder.Entity<Album>().HasOne(e => e.UserOwner).WithMany(e => e.OwnedAlbums);
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
@@ -72,6 +73,19 @@ public class LactoseDbContext : DbContext {
modelBuilder.Entity<Asset>().HasMany(e => e.Tags).WithMany(e => e.Assets);
modelBuilder.Entity<Asset>().HasMany(e => e.Faces).WithOne(e => e.Asset);
modelBuilder.Entity<Asset>().HasOne(e => e.Folder).WithMany(e => e.Assets);
modelBuilder.Entity<Asset>().HasIndex(e => new { e.IsPubliclyShared, e.OwnerId })
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
// People
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
.HasDatabaseName("IX_People_Name_Trgm")
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
// Albums
modelBuilder.Entity<Album>().HasIndex(a => a.Title)
.HasDatabaseName("IX_Albums_Title_Trgm")
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
//Face Relationships
modelBuilder.Entity<Face>().HasOne(e => e.Asset).WithMany(e => e.Faces);
modelBuilder.Entity<Face>().HasOne(e => e.Person).WithMany(e => e.Faces);

View File

@@ -78,32 +78,21 @@ public class AlbumController(
public ActionResult<List<AlbumPreviewDto>> Search([FromQuery] AlbumSearchParametersDto pagingOptions) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 0) return BadRequest();
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 1 || pagingOptions.PageSize > PagedParametersDto.MaxPageSize) return BadRequest();
var albums = albumRepository.SearchQuery(
pagingOptions.Search ?? string.Empty,
pagingOptions.Page,
pagingOptions.PageSize,
pagingOptions.SortBy,
pagingOptions.SortAsc,
pagingOptions.Unassigned
pagingOptions.Unassigned,
uid ?? default,
accessLevel
).ToList();
switch (accessLevel) {
default:
case EAccessLevel.User:
List<Album> filteredAlbums;
// Only publicly shared albums or owned by user
filteredAlbums = albums.Where(album => album.Assets != null && (album.UserOwnerId == uid || album.Assets.Any(asset => asset.IsPubliclyShared && asset.DeletedAt == null))).ToList();
// Add shared albums
filteredAlbums.AddRange(albums.Where(album => album.Assets != null && album.Assets.Any(x => x.DeletedAt == null && x.SharedWith!.Any(user => user.Id == uid))));
return Ok(filteredAlbums.Select(x => x.ToAlbumPreviewDto()).ToList());
case EAccessLevel.Curator:
case EAccessLevel.Admin:
// All albums
return Ok(albums.Select(x => x.ToAlbumPreviewDto()).ToList());
}
return Ok(albums);
}
/// <summary>

View File

@@ -124,7 +124,7 @@ public class AssetController(
return BadRequest();
}
if (searchOptionsDto.Page < 1 || searchOptionsDto.PageSize < 1) {
if (searchOptionsDto.Page < 0 || searchOptionsDto.PageSize < 1 || searchOptionsDto.PageSize > PagedParametersDto.MaxPageSize) {
logger.LogWarning("Invalid pagination parameters provided");
return BadRequest();
}

View File

@@ -1,3 +1,4 @@
using Butter.Dtos;
using Butter.Dtos.Jobs;
using Butter.Types;
using Lactose.Jobs;
@@ -34,7 +35,8 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
/// </summary>
[HttpGet("past")]
[Authorize(Roles = "Admin")]
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 1, [FromQuery] int pageSize = 5) {
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5) {
if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);

View File

@@ -102,16 +102,17 @@ public class PersonController(
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (pagedSearch.Page < 0 || pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
var people = personRepository.SearchQuery(
pagedSearch.Search ?? string.Empty,
pagedSearch.Page,
pagedSearch.PageSize,
pagedSearch.SortBy,
pagedSearch.SortAsc,
uid!.Value,
uid ?? default,
accessLevel
).Select(p => p.ToPersonPreviewDto())
.ToList();
).ToList();
return Ok(people);
}

View File

@@ -67,10 +67,10 @@ public class TagController(
/// <param name="pagedSearch">Pagination and search parameters.</param>
/// <returns>A list of tags matching the search criteria.</returns>
[HttpGet]
public ActionResult<List<TagDto>> GetAll(PagedSearchParametersDto pagedSearch) {
public ActionResult<List<TagDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
pagedSearch.Search ??= string.Empty;
if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); }
if(pagedSearch.PageSize < 0) { return BadRequest("PageSize must be greater than 0"); }
if(pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); }
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
List<TagDto> tagsDto = [];

View File

@@ -97,18 +97,10 @@ namespace Lactose.Controllers {
/// Gets all users.
/// </summary>
/// <returns>A list of all users.</returns>
[Authorize(Roles = "Admin")]
Review

how social do we want this to become? We'll change this in future I guess

how social do we want this to become? We'll change this in future I guess
Review

It was structured to be possible to search other user, but yet again we need a defined use cases of what we want to actually do

It was structured to be possible to search other user, but yet again we need a defined use cases of what we want to actually do
[HttpGet]
public ActionResult<List<UserInfoDto>> GetAll() {
logger.LogDebug("Sending Users list");
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser == null) {
logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here");
return Unauthorized("Unauthorized");
}
//TODO: Lower level of access should fill less information of users
//if (false) return Unauthorized();
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto()).ToList());
}

View File

@@ -1,4 +1,5 @@
using Butter.Dtos.Album;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
@@ -7,6 +8,44 @@ namespace Lactose.Mapper;
/// provides mapping methods for converting Album objects to DTOs.
/// </summary>
public static class AlbumMapper {
/// <summary>
/// Maps an Album object to an AlbumPreviewDto object.
/// </summary>
/// <param name="album">The album to map.</param>
/// <returns>An AlbumPreviewDto object.</returns>
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album) => new AlbumPreviewDto {
Id = album.Id,
Name = album.Title,
Owner = album.UserOwnerId,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = album.Assets?.Count ?? 0
};
/// <summary>
/// Maps an Album object to an AlbumPreviewDto with visibility-aware asset counting.
/// </summary>
/// <param name="album">The album to map.</param>
/// <param name="userId">The requesting user's ID (used for visibility filtering).</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <returns>An AlbumPreviewDto object.</returns>
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album, Guid userId, EAccessLevel accessLevel) => new AlbumPreviewDto {
Id = album.Id,
Name = album.Title,
Owner = album.UserOwnerId,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = accessLevel == EAccessLevel.User
? album.Assets?.Count(asset =>
asset.DeletedAt == null && (
asset.IsPubliclyShared ||
asset.OwnerId == userId ||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId)))) ?? 0
: album.Assets?.Count ?? 0
};
/// <summary>
/// Maps an Album object to an AlbumFullDto object.
/// </summary>
@@ -31,21 +70,4 @@ public static class AlbumMapper {
}).ToList()
};
/// <summary>
/// Maps an Album object to an AlbumPreviewDto object.
/// </summary>
/// <param name="album">The album to map.</param>
/// <returns>An AlbumPreviewDto object.</returns>
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album) => new AlbumPreviewDto {
Id = album.Id,
Name = album.Title,
Owner = album.UserOwnerId,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = album.Assets?.Count ?? 0
};
}

View File

@@ -0,0 +1,581 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260712160403_AddAssetsSearchVisibilityIndex")]
partial class AddAssetsSearchVisibilityIndex
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.HasIndex("IsPubliclyShared", "OwnerId")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddAssetsSearchVisibilityIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets",
columns: new[] { "IsPubliclyShared", "OwnerId" },
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets");
}
}
}

View File

@@ -0,0 +1,594 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260712162406_AddTrigramIndexes")]
partial class AddTrigramIndexes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.HasIndex("IsPubliclyShared", "OwnerId")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddTrigramIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.Annotation("Npgsql:PostgresExtension:vector", ",,")
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.CreateIndex(
name: "IX_People_Name_Trgm",
table: "People",
column: "Name")
.Annotation("Npgsql:IndexMethod", "gin")
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
migrationBuilder.CreateIndex(
name: "IX_Albums_Title_Trgm",
table: "Albums",
column: "Title")
.Annotation("Npgsql:IndexMethod", "gin")
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_People_Name_Trgm",
table: "People");
migrationBuilder.DropIndex(
name: "IX_Albums_Title_Trgm",
table: "Albums");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:vector", ",,")
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
}
}
}

View File

@@ -21,6 +21,7 @@ namespace Lactose.Migrations
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -100,6 +101,12 @@ namespace Lactose.Migrations
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
@@ -194,6 +201,10 @@ namespace Lactose.Migrations
b.HasIndex("OwnerId");
b.HasIndex("IsPubliclyShared", "OwnerId")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
@@ -330,6 +341,12 @@ namespace Lactose.Migrations
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");

View File

@@ -1,4 +1,7 @@
using Butter.Dtos.Album;
using Butter.Types;
using Lactose.Context;
using Lactose.Mapper;
using Lactose.Models;
namespace Lactose.Repositories;
@@ -23,28 +26,59 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
}
/// <inheritdoc />
public IEnumerable<Album> SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false) {
IQueryable<Album> albumsQuery = context.Albums
.Include(a => a.CoverAsset)
.Include(a => a.PersonOwner)
.Include(a => a.Assets)!.ThenInclude(a => a.SharedWith)
.Where(x => x.Title.Contains(query));
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) {
IQueryable<Album> albumsQuery = context.Albums;
if (!string.IsNullOrEmpty(query))
albumsQuery = albumsQuery.Where(x => EF.Functions.ILike(x.Title, $"%{query}%"));
if (unassigned)
albumsQuery = albumsQuery.Where(a => a.PersonOwnerId == null);
IOrderedQueryable<Album> ordered = sortBy?.ToLower() switch
{
"name" => sortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title),
"updated" => sortAsc ? albumsQuery.OrderBy(a => a.UpdatedAt) : albumsQuery.OrderByDescending(a => a.UpdatedAt),
"assets" => sortAsc ? albumsQuery.OrderBy(a => a.Assets.Count) : albumsQuery.OrderByDescending(a => a.Assets.Count),
"person" => sortAsc ? albumsQuery.OrderBy(a => a.PersonOwner!.Name): albumsQuery.OrderByDescending(a => a.PersonOwner!.Name),
_ => sortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt),
"name" => sortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title),
"updated" => sortAsc ? albumsQuery.OrderBy(a => a.UpdatedAt) : albumsQuery.OrderByDescending(a => a.UpdatedAt),
"assets" => sortAsc
? albumsQuery.OrderBy(a => a.Assets!.Count)
: albumsQuery.OrderByDescending(a => a.Assets!.Count),
"person" => sortAsc ? albumsQuery.OrderBy(a => a.PersonOwner!.Name) : albumsQuery.OrderByDescending(a => a.PersonOwner!.Name),
_ => sortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt),
};
return ordered
.Skip(page * pageSize)
.Take(pageSize);
var pageOffset = page * pageSize;
var albumIds = ordered
.Skip(pageOffset)
.Take(pageSize)
.Select(a => a.Id)
.ToList();
if (albumIds.Count == 0)
return [];
// Load with includes for client-side visibility filtering
var pagedAlbums = context.Albums
.Include(a => a.Assets)!.ThenInclude(a => a.SharedWith)
.Include(a => a.PersonOwner)
.Where(a => albumIds.Contains(a.Id))
.ToList();
// Filter out albums with no visible content for regular users
if (accessLevel == EAccessLevel.User)
pagedAlbums = pagedAlbums.Where(a =>
a.Assets != null && a.Assets.Any(asset =>
asset.DeletedAt == null && (
asset.IsPubliclyShared ||
asset.OwnerId == userId ||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
))
).ToList();
// Project to DTOs
REDCODE marked this conversation as resolved
Review

Don't we have an overload method in the mapper that allows us to to an album.ToAlbumPreviewDto()?

Don't we have an overload method in the mapper that allows us to to an album.ToAlbumPreviewDto()?
var dtos = pagedAlbums.Select(a => a.ToAlbumPreviewDto(userId, accessLevel)).ToList();
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
}
/// <inheritdoc />

View File

@@ -20,7 +20,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
/// <inheritdoc />
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize) =>
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to)
.Skip((pageNumber - 1) * pageSize)
.Skip(pageNumber * pageSize)
.Take(pageSize);
/// <inheritdoc />
@@ -150,7 +150,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
var seedHash = seed.Value.GetHashCode();
var shuffledIds = allIds
.OrderBy(id => DeterministicHash(id, seedHash))
.Skip((pageNumber - 1) * pageSize)
.Skip(pageNumber * pageSize)
.Take(pageSize)
.ToList();
@@ -166,7 +166,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
dataQuery = orderRandomly ? dataQuery.OrderBy(a => a.Id) : dataQuery.OrderByDescending(a => a.CreatedAt);
return dataQuery
.Skip((pageNumber - 1) * pageSize)
.Skip(pageNumber * pageSize)
.Take(pageSize)
.ToList();
}

View File

@@ -1,3 +1,5 @@
using Butter.Dtos.Album;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Repositories;
@@ -72,7 +74,7 @@ public interface IAlbumRepository : IDisposable {
public void Remove(Album album);
/// <summary>
/// Searches for albums based on a query string with optional sorting and filtering.
/// Searches for albums based on a query string with optional sorting, filtering, and access-level scoping.
/// </summary>
/// <param name="query">The search query string to filter by title.</param>
/// <param name="page">The page number for pagination (default is 0).</param>
@@ -80,7 +82,9 @@ public interface IAlbumRepository : IDisposable {
/// <param name="sortBy">The field to sort by (e.g. "name", "created", "updated", "assets", "person"). When <c>null</c>, defaults to created descending.</param>
/// <param name="sortAsc">Whether to sort ascending. Default is <c>false</c> (descending).</param>
/// <param name="unassigned">When <c>true</c>, only return albums with no person assigned.</param>
/// <returns>A list of albums that match the search query.</returns>
public IEnumerable<Album> SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false);
/// <param name="userId">The current user's ID for access-level filtering.</param>
/// <param name="accessLevel">The current user's access level. Regular users only see albums with visible assets.</param>
/// <returns>A list of album previews matching the search query.</returns>
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User);
}

View File

@@ -167,7 +167,7 @@ public interface IAssetRepository : IDisposable {
/// <param name="to">Optional end date for the date range filter.</param>
/// <param name="orderRandomly">If true, orders randomly instead of by date.</param>
/// <param name="seed">An optional seed for deterministic random ordering across paginated requests.</param>
/// <param name="pageNumber">The 1-based page number.</param>
/// <param name="pageNumber">The zero-based page number.</param>
/// <param name="pageSize">The number of items per page.</param>
/// <param name="total">The total count of matching assets.</param>
/// <returns>A paginated collection of assets matching the filters.</returns>

View File

@@ -1,3 +1,4 @@
using Butter.Dtos.Person;
using Lactose.Models;
namespace Lactose.Repositories;
@@ -64,8 +65,8 @@ public interface IPersonRepository : IDisposable {
/// <param name="sortAsc">Whether to sort ascending. Default is <c>true</c>.</param>
/// <param name="userId">The current user's ID for access-level filtering.</param>
/// <param name="accessLevel">The current user's access level.</param>
/// <returns>A paginated list of people matching the query.</returns>
IEnumerable<Person> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User);
/// <returns>A paginated list of person previews matching the query.</returns>
IEnumerable<PersonPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User);
/// <summary>
/// Finds multiple people by their IDs.

View File

@@ -35,7 +35,7 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor
return query
.OrderByDescending(j => j.Finished ?? j.Created)
.Skip((page - 1) * pageSize)
.Skip(page * pageSize)
.Take(pageSize)
.ToList();
}

View File

@@ -1,3 +1,4 @@
using Butter.Dtos.Person;
using Butter.Types;
using Lactose.Context;
using Lactose.Models;
@@ -29,18 +30,16 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
if (accessLevel != EAccessLevel.User)
return GetAll();
var all = context.People
.Include(p => p.Albums)!
.ThenInclude(a => a.Assets)!
.ThenInclude(a => a.SharedWith)
return context.People
.Include(p => p.Albums)
.Where(p => p.Albums!.Any(a =>
a.Assets!.Any(asset => asset.DeletedAt == null && (
asset.IsPubliclyShared ||
asset.OwnerId == userId ||
asset.SharedWith!.Any(u => u.Id == userId)
))
))
.ToList();
return all.Where(p => p.Albums != null && p.Albums.Any(a =>
a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && (
asset.IsPubliclyShared ||
asset.OwnerId == userId ||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
))));
}
/// <inheritdoc />
@@ -59,35 +58,71 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
public void Remove(Person person) => context.People.Remove(person);
/// <inheritdoc />
public IEnumerable<Person> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) {
IQueryable<Person> peopleQuery = context.People
.Include(p => p.Albums);
public IEnumerable<PersonPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) {
IQueryable<Person> peopleQuery = context.People;
// For regular users, only show people who have at least one album with visible assets
if (accessLevel == EAccessLevel.User) {
peopleQuery = peopleQuery.Where(p => p.Albums != null && p.Albums.Any(a =>
a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && (
asset.IsPubliclyShared ||
asset.OwnerId == userId ||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
))));
}
// Apply search filter
if (!string.IsNullOrEmpty(query))
peopleQuery = peopleQuery.Where(p => p.Name.Contains(query));
peopleQuery = peopleQuery.Where(p => EF.Functions.ILike(p.Name, $"%{query}%"));
// Apply sorting
// Apply sorting — use subquery counts for "albums"
IOrderedQueryable<Person> ordered = sortBy?.ToLower() switch
{
"created" => sortAsc ? peopleQuery.OrderBy(p => p.CreatedAt) : peopleQuery.OrderByDescending(p => p.CreatedAt),
"albums" => sortAsc ? peopleQuery.OrderBy(p => p.Albums.Count) : peopleQuery.OrderByDescending(p => p.Albums.Count),
_ => sortAsc ? peopleQuery.OrderBy(p => p.Name) : peopleQuery.OrderByDescending(p => p.Name),
"created" => sortAsc ? peopleQuery.OrderBy(p => p.CreatedAt) : peopleQuery.OrderByDescending(p => p.CreatedAt),
"albums" => sortAsc
? peopleQuery.OrderBy(p => p.Albums!.Count)
: peopleQuery.OrderByDescending(p => p.Albums!.Count),
_ => sortAsc ? peopleQuery.OrderBy(p => p.Name) : peopleQuery.OrderByDescending(p => p.Name),
};
return ordered
.Skip(page * pageSize)
.Take(pageSize);
// Two-step pagination: first get the IDs in the correct order
var pageOffset = page * pageSize;
var personIds = ordered
.Skip(pageOffset)
.Take(pageSize)
.Select(p => p.Id)
.ToList();
if (personIds.Count == 0)
return [];
// Load with includes for client-side visibility filtering
var pagedPeople = context.People
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)!.ThenInclude(a => a.SharedWith)
.Where(p => personIds.Contains(p.Id))
.ToList();
// Filter out people with no visible content for regular users
if (accessLevel == EAccessLevel.User)
pagedPeople = pagedPeople.Where(p =>
p.Albums != null && p.Albums.Any(a =>
a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && (
asset.IsPubliclyShared ||
asset.OwnerId == userId ||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
)))
).ToList();
// Project to DTOs
var dtos = pagedPeople.Select(p => new PersonPreviewDto {
Id = p.Id,
Name = p.Name,
ProfileAssetId = p.ProfileAssetId,
ProfileCropX = p.ProfileCropX,
ProfileCropY = p.ProfileCropY,
ProfileCropZoom = p.ProfileCropZoom,
TotalAlbums = accessLevel == EAccessLevel.User
? p.Albums?.Count(a =>
a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && (
asset.IsPubliclyShared ||
asset.OwnerId == userId ||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
))) ?? 0
: p.Albums?.Count ?? 0
}).ToList();
// Reorder to match the paginated order
var orderMap = personIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
}
/// <inheritdoc />

View File

@@ -64,47 +64,52 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti
_ = int.TryParse(previewSizeSetting?.Value, out var expectedPreviewSize);
dto.AssetsMissingThumbnail = context.Assets.Count(a =>
(a.ThumbnailPath == "") && a.DeletedAt == null);
(a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null);
dto.AssetsMissingThumbnailStale = expectedThumbnailSize > 0
? context.Assets.Count(a =>
a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize && a.DeletedAt == null)
a.ThumbnailPath != null && a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize && a.DeletedAt == null)
: 0;
dto.AssetsMissingPreviews = context.Assets.Count(a =>
(a.PreviewPath == "") && a.DeletedAt == null);
(a.PreviewPath == null || a.PreviewPath == "") && a.DeletedAt == null);
dto.AssetsMissingPreviewsStale = expectedPreviewSize > 0
? context.Assets.Count(a =>
a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize && a.DeletedAt == null)
a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize && a.DeletedAt == null)
: 0;
var emptyHash = new BitArray(64);
dto.AssetsMissingPhash = context.Assets.Count(a =>
a.Hash == emptyHash && a.DeletedAt == null);
var albumAssetSet = context.Set<Dictionary<string, object>>("AlbumAsset");
dto.AssetsWithNoAlbum = context.Assets.Count(a =>
a.DeletedAt == null && !(a.Albums ?? Enumerable.Empty<Album>()).Any());
a.DeletedAt == null && !albumAssetSet.Any(aa => EF.Property<Guid?>(aa, "AssetsId") == a.Id));
dto.AssetsWithNoPerson = context.Assets.Count(a =>
a.DeletedAt == null && (a.Albums ?? Enumerable.Empty<Album>()).All(al => al.PersonOwnerId == null));
a.DeletedAt == null && !albumAssetSet.Any(aa =>
EF.Property<Guid?>(aa, "AssetsId") == a.Id
&& context.Albums.Any(al => al.Id == EF.Property<Guid?>(aa, "AlbumsId") && al.PersonOwnerId != null)));
dto.AlbumsMissingCover = context.Albums.Count(a => a.CoverAssetId == null);
dto.CosplayersMissingCover = 0;
dto.TopTags = context.Assets
.Where(a => a.DeletedAt == null)
.SelectMany(a => a.Tags ?? Enumerable.Empty<Tag>())
.GroupBy(t => new { t.Id, t.Name })
.Select(g => new TagStatDto {
dto.TopTags = (
from at in context.Set<Dictionary<string, object>>("AssetTag")
join a in context.Assets.Where(a => a.DeletedAt == null)
on EF.Property<Guid?>(at, "AssetsId") equals (Guid?)a.Id
join t in context.Tags
on EF.Property<Guid?>(at, "TagsId") equals (Guid?)t.Id
group t by new { t.Id, t.Name } into g
select new TagStatDto {
Id = g.Key.Id,
Name = g.Key.Name,
AssetCount = g.Count()
})
.OrderByDescending(t => t.AssetCount)
.Take(10)
.ToList();
}
).OrderByDescending(t => t.AssetCount).Take(10).ToList();
var resolutions = context.Assets
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
{
"dev": {
"rngUser": "preFilledValue",
"rngPassword": "preFilledValue"
"userName": "preFilledValue",
"userPwd": "preFilledValue",
"curatorName": "preFilledValue",
"curatorPwd": "preFilledValue"
}
}

View File

@@ -35,15 +35,7 @@
</button>
} else {
<div class="d-flex flex-column flex-sm-row align-items-sm-center gap-2 mt-2 mt-sm-0">
<div class="d-flex">
<input class="form-control form-control-sm"
style="border-top-right-radius: 0; border-bottom-right-radius: 0" type="search"
placeholder="Search anything..." aria-label="search"/>
<button class="btn btn-sm btn-success"
style="border-top-left-radius: 0; border-bottom-left-radius: 0" type="submit">
<i class="bi bi-search-heart"></i>
</button>
</div>
<SearchDropdown />
<div class="btn-group btn-group-sm">
<button class="btn btn-light d-flex align-items-center gap-1" type="button" @onclick="OnProfileClick">
<div class="nav-avatar">@(loginService.LoggedUser?.Username?.Length > 0 ? loginService.LoggedUser.Username[..1].ToUpper() : "?")</div>

View File

@@ -222,6 +222,22 @@
selectMode = false;
selectedImageIds.Clear();
album = await albumService.GetAlbumAsync(Id);
if (album != null) {
var seen = new Dictionary<Guid, int>();
var uniqueImages = new List<Guid>(album.Images.Count);
var uniquePreviews = new List<AlbumAssetPreviewDto>(album.AssetPreviews.Count);
for (var i = 0; i < album.Images.Count; i++) {
var id = album.Images[i];
if (!seen.ContainsKey(id)) {
seen[id] = i;
uniqueImages.Add(id);
if (i < album.AssetPreviews.Count)
uniquePreviews.Add(album.AssetPreviews[i]);
}
}
album.Images = uniqueImages;
album.AssetPreviews = uniquePreviews;
}
isLoading = false;
}

View File

@@ -95,11 +95,28 @@
};
protected override async Task OnInitializedAsync() {
ParseSearchQuery();
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
_loggedUserChangedHandler = (_, _) => StateHasChanged();
loginService.LoggedUserChanged += _loggedUserChangedHandler;
}
protected override void OnParametersSet() {
ParseSearchQuery();
}
void ParseSearchQuery() {
var uri = navigationManager.Uri;
var qIdx = uri.IndexOf('?');
if (qIdx < 0) return;
foreach (var part in uri[(qIdx + 1)..].Split('&'))
{
var kv = part.Split('=', 2);
if (kv.Length == 2 && kv[0] == "search")
searchQuery = Uri.UnescapeDataString(kv[1].Replace("+", " "));
}
}
void SetMasonry() => viewMode = "masonry";
void SetGrid() => viewMode = "grid";

View File

@@ -79,7 +79,7 @@
SortOptions="@albumSortOptions">
<ActionButtons>
<div class="d-flex gap-2 align-items-start">
@if (allAlbums.Count > 0) {
@if (allAlbumsDict.Count > 0) {
<div class="btn-group btn-group-sm">
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
@onclick="SetMasonry" title="Masonry">
@@ -95,7 +95,7 @@
<button class="btn btn-success btn-sm" @onclick="OpenAlbumAssigner">
<i class="bi bi-plus-circle"></i> Add Albums
</button>
@if (allAlbums.Count > 0) {
@if (allAlbumsDict.Count > 0) {
if (selectMode) {
<button class="btn btn-warning btn-sm" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
@@ -117,9 +117,9 @@
</ActionButtons>
</SortFilterBar>
@if (allAlbums.Count > 0) {
@if (allAlbumsDict.Count > 0) {
<div class="@("album-grid " + viewMode)">
@foreach (var album in allAlbums) {
@foreach (var album in allAlbumsDict.Values) {
<AlbumCard Album="album"
SelectionMode="selectMode"
Selected="selectedAlbumIds.Contains(album.Id)"
@@ -214,7 +214,7 @@
};
// Infinite scroll
List<AlbumPreviewDto> allAlbums = [];
Dictionary<Guid, AlbumPreviewDto> allAlbumsDict = [];
int currentAlbumPage;
const int AlbumPageSize = 30;
bool hasMoreAlbums = true;
@@ -243,7 +243,7 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (allAlbums.Count > 0 && dotNetRef == null) {
if (allAlbumsDict.Count > 0 && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"albumObserver.observeBottom", sentinelRef, dotNetRef
@@ -253,13 +253,14 @@
async Task LoadPerson() {
isLoading = true;
allAlbums.Clear();
allAlbumsDict.Clear();
currentAlbumPage = 0;
hasMoreAlbums = true;
person = await personService.GetByIdAsync(Id, 0, AlbumPageSize);
if (person?.Albums != null) {
allAlbums = person.Albums;
foreach (var a in person.Albums)
allAlbumsDict[a.Id] = a;
hasMoreAlbums = person.Albums.Count >= AlbumPageSize;
currentAlbumPage = 1;
}
@@ -279,9 +280,10 @@
hasMoreAlbums = true;
person = await personService.GetByIdAsync(Id, 0, AlbumPageSize, albumSearchQuery, albumSortBy, albumSortAsc);
allAlbums.Clear();
allAlbumsDict.Clear();
if (person?.Albums != null) {
allAlbums = person.Albums;
foreach (var a in person.Albums)
allAlbumsDict[a.Id] = a;
hasMoreAlbums = person.Albums.Count >= AlbumPageSize;
currentAlbumPage = 1;
}
@@ -297,7 +299,8 @@
var fresh = await personService.GetByIdAsync(Id, currentAlbumPage, AlbumPageSize, albumSearchQuery, albumSortBy, albumSortAsc);
if (fresh?.Albums?.Count > 0) {
allAlbums.AddRange(fresh.Albums);
foreach (var a in fresh.Albums)
allAlbumsDict[a.Id] = a;
currentAlbumPage++;
}
hasMoreAlbums = fresh?.Albums?.Count >= AlbumPageSize;

View File

@@ -80,6 +80,22 @@
loginService.LoggedUserChanged += OnLoggedUserChanged;
}
protected override void OnParametersSet() {
ParseSearchQuery();
}
void ParseSearchQuery() {
var uri = navigationManager.Uri;
var qIdx = uri.IndexOf('?');
if (qIdx < 0) return;
foreach (var part in uri[(qIdx + 1)..].Split('&'))
{
var kv = part.Split('=', 2);
if (kv.Length == 2 && kv[0] == "search")
searchQuery = Uri.UnescapeDataString(kv[1].Replace("+", " "));
}
}
void OnLoggedUserChanged(object? _, UserInfoDto? _2) => StateHasChanged();
void NavigateToPerson(Guid id) {

View File

@@ -29,10 +29,8 @@
}
<div class="masonry-grid">
@{ var idx = 0; }
@foreach (var page in allAssetPages) {
@foreach (var asset in page) {
var i = idx++;
@for (int i = 0; i < flatList.Count; i++) {
var asset = flatList[i];
<div class="masonry-tile" @key="asset.Id"
style="aspect-ratio:@GetAspectRatio(asset)"
@onclick="() => OpenPreview(asset, i)">
@@ -72,7 +70,6 @@
}
</div>
}
}
</div>
@if (isLoadingMore) {
@@ -172,13 +169,13 @@
async Task LoadFirstPage() {
_randomSeed = Guid.NewGuid();
isLoading = true;
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 1, 30);
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 0, 30);
if (items?.Count > 0) {
allAssetPages.Add(items);
RebuildFlatList();
loadedMinPage = 1;
loadedMaxPage = 1;
currentPage = 2;
loadedMinPage = 0;
loadedMaxPage = 0;
currentPage = 1;
hasMoreUp = false;
}
hasMore = items?.Count == 30;
@@ -211,12 +208,12 @@
[JSInvokable]
public async Task LoadPreviousPage() {
if (isLoadingUp || !hasMoreUp || loadedMinPage <= 1) return;
if (isLoadingUp || !hasMoreUp || loadedMinPage <= 0) return;
isLoadingUp = true;
StateHasChanged();
var pageToLoad = loadedMinPage - 1;
if (pageToLoad < 1) {
if (pageToLoad < 0) {
hasMoreUp = false;
isLoadingUp = false;
return;
@@ -234,12 +231,18 @@
}
}
hasMoreUp = loadedMinPage > 1;
hasMoreUp = loadedMinPage > 0;
isLoadingUp = false;
StateHasChanged();
}
void RebuildFlatList() => flatList = allAssetPages.SelectMany(p => p).ToList();
void RebuildFlatList() {
var seen = new Dictionary<Guid, AssetPreviewDto>();
foreach (var page in allAssetPages)
foreach (var asset in page)
seen.TryAdd(asset.Id, asset);
flatList = [.. seen.Values];
}
void OpenPreview(AssetPreviewDto asset, int index) {
selectedAsset = asset;

View File

@@ -1,4 +1,5 @@
@using Microsoft.Extensions.Options
@using MilkStream.Client.Services
@using System.Linq
@inject LoginService loginService

View File

@@ -10,7 +10,7 @@
@if (isLoading) {
<LoadSpinner/>
} else if (people.Count == 0 && !hasMore) {
} else if (peopleDict.Count == 0 && !hasMore) {
<EmptyState Variant="warning" Title="No media available">
@if (loginService.IsLoggedIn) {
<p>There are no cosplayers yet.</p>
@@ -20,7 +20,7 @@
</EmptyState>
} else {
<div class="cosplayers-grid">
@foreach (var person in people) {
@foreach (var person in peopleDict.Values) {
<div class="cosplayer-card @(SelectionMode ? "cosplayer-card-select" : "")" @key="person.Id"
@onclick="() => HandleCardClick(person.Id)"
@onclick:preventDefault="true">
@@ -78,7 +78,7 @@
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
[Parameter] public int LoadVersion { get; set; }
List<PersonPreviewDto> people = [];
Dictionary<Guid, PersonPreviewDto> peopleDict = [];
int currentPage = 0;
bool hasMore = true;
bool isLoading = true;
@@ -115,7 +115,7 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (people.Count > 0 && dotNetRef == null) {
if (peopleDict.Count > 0 && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"cosplayerObserver.observeBottom", sentinelRef, dotNetRef
@@ -125,7 +125,7 @@
async Task Reload() {
await DisposeObserver();
people.Clear();
peopleDict.Clear();
currentPage = 0;
hasMore = true;
isLoading = true;
@@ -133,7 +133,8 @@
var items = await personService.GetAllAsync(0, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
people = items;
foreach (var p in items)
peopleDict[p.Id] = p;
currentPage = 1;
}
hasMore = items?.Count == 30;
@@ -148,7 +149,8 @@
var items = await personService.GetAllAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
people.AddRange(items);
foreach (var p in items)
peopleDict[p.Id] = p;
currentPage++;
}
hasMore = items?.Count == 30;

View File

@@ -1,6 +1,7 @@
@using Butter.Dtos.Album
@using Butter.Dtos.Person
@using Microsoft.Extensions.Options
@using MilkStream.Client.Services
@namespace MilkStream.Client.Components.Shared

View File

@@ -0,0 +1,95 @@
.search-dropdown-menu {
display: block;
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 400px;
overflow-y: auto;
margin-top: 0.25rem;
}
.search-dropdown-header {
display: flex !important;
align-items: center;
justify-content: space-between;
text-transform: uppercase;
font-size: 0.75rem !important;
font-weight: 600 !important;
letter-spacing: 0.05em;
color: var(--bs-secondary-color, #6c757d) !important;
}
.search-dropdown-header .bi {
font-size: 0.7rem;
opacity: 0.5;
transition: opacity 0.1s, transform 0.1s;
}
.search-dropdown-header:hover .bi,
.search-dropdown-header.active .bi {
opacity: 0.8;
transform: translateX(1px);
}
.search-dropdown-item-flex {
display: flex !important;
align-items: center;
gap: 0.5rem;
}
.search-dropdown-thumb {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.search-dropdown-thumb-rounded {
width: 32px;
height: 32px;
border-radius: 0.375rem;
object-fit: cover;
flex-shrink: 0;
}
.search-dropdown-fallback {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent, #0d6efd);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}
.search-dropdown-fallback-rounded {
width: 32px;
height: 32px;
border-radius: 0.375rem;
background: var(--accent, #0d6efd);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.search-dropdown-fallback .bi {
font-size: 1rem;
}
.search-dropdown-item-title {
font-size: 0.875rem;
line-height: 1.25;
}
.dropdown-item small {
font-size: 0.75rem;
line-height: 1.2;
}