Global "Search Anything" typeahead dropdown in navbar #91
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) {
|
||||
|
REDCODE marked this conversation as resolved
Outdated
|
||||
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();
|
||||
|
||||
|
REDCODE marked this conversation as resolved
Outdated
REDCODE
commented
Page 0 should be the first page, what are we doing here? Page 0 should be the first page, what are we doing here?
|
||||
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) {
|
||||
|
REDCODE marked this conversation as resolved
Outdated
REDCODE
commented
I'll just add a note here, this is temporary and #95 should take care of this at a later stage. I'll just add a note here, this is temporary and #95 should take care of this at a later stage.
|
||||
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) {
|
||||
|
REDCODE marked this conversation as resolved
Outdated
Fastwind
commented
We could extend page size to 1000 rows if the row doesn't weight too much We could extend page size to 1000 rows if the row doesn't weight too much
as this is admin only I don't expect the need for security limits
REDCODE
commented
Job page interface is paginated because it also then fetches separately child job data. Kind of an hard to run query but in theory it should be kept clean by the max retention time Job page interface is paginated because it also then fetches separately child job data. Kind of an hard to run query but in theory it should be kept clean by the max retention time
|
||||
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"); }
|
||||
|
REDCODE marked this conversation as resolved
Outdated
Fastwind
commented
Can we have a single const with the max PageSize, instead of using magic numbers Can we have a single const with the max PageSize, instead of using magic numbers
REDCODE
commented
Sure, Where do we stash it? Are we making a static class? Sure, Where do we stash it? Are we making a static class?
Fastwind
commented
it could be a public static property from the same paged class, unless you plan to have a config for that too it could be a public static property from the same paged class, unless you plan to have a config for that too
REDCODE
commented
On it On it
|
||||
if(pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); }
|
||||
|
REDCODE marked this conversation as resolved
Outdated
REDCODE
commented
Here aswell like before... Page 0 shoudl be valid, not the opposite. Here aswell like before... Page 0 shoudl be valid, not the opposite.
|
||||
|
||||
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")]
|
||||
|
REDCODE
commented
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
Fastwind
commented
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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
REDCODE marked this conversation as resolved
Outdated
REDCODE
commented
This should be kept This should be kept
|
||||
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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Are we sure to enable search queries to non users?
As search is an heavy request to execute, it may lead to dos attack without proper limitations
Uh... being able to search albums/cosplayers and such it's kinda needed when there is way too much content... Funny enough, the search seems to be very inexpensive for now, sorting ends up being the worst (see relevant issues as #98 #101)
Still if it's the server doing it, it doesn't resolve my question
it's more of a yes or no to my question,
followed by a new security issue in case of yes
@Ai_Agent remove allow anonymus for now, we will enable this when it's time with prooper rate limiting