Global "Search Anything" typeahead dropdown in navbar #91
25
AGENTS.md
25
AGENTS.md
@@ -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 1–250).
|
||||
- **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.
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -97,18 +97,10 @@ namespace Lactose.Controllers {
|
||||
/// Gets all users.
|
||||
/// </summary>
|
||||
/// <returns>A list of all users.</returns>
|
||||
[Authorize(Roles = "Admin")]
|
||||
|
|
||||
[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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
581
Lactose/Migrations/20260712160403_AddAssetsSearchVisibilityIndex.Designer.cs
generated
Normal file
581
Lactose/Migrations/20260712160403_AddAssetsSearchVisibilityIndex.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
594
Lactose/Migrations/20260712162406_AddTrigramIndexes.Designer.cs
generated
Normal file
594
Lactose/Migrations/20260712162406_AddTrigramIndexes.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Lactose/Migrations/20260712162406_AddTrigramIndexes.cs
Normal file
50
Lactose/Migrations/20260712162406_AddTrigramIndexes.cs
Normal 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", ",,");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
REDCODE
commented
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 />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"dev": {
|
||||
"rngUser": "preFilledValue",
|
||||
"rngPassword": "preFilledValue"
|
||||
"userName": "preFilledValue",
|
||||
"userPwd": "preFilledValue",
|
||||
"curatorName": "preFilledValue",
|
||||
"curatorPwd": "preFilledValue"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@using Microsoft.Extensions.Options
|
||||
@using MilkStream.Client.Services
|
||||
@using System.Linq
|
||||
|
||||
@inject LoginService loginService
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
95
MilkStream.Client/Components/Shared/SearchDropdown.razor.css
Normal file
95
MilkStream.Client/Components/Shared/SearchDropdown.razor.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user
how social do we want this to become? We'll change this in future I guess
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