Compare commits
60
Commits
v1.0.1
...
d3124c55bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3124c55bd | ||
|
|
c97b16ada9 | ||
|
|
5e7c053218 | ||
|
|
e1bb7d3330 | ||
|
|
0c8e653a5f | ||
|
|
84d3f63a97 | ||
|
|
9e9d091f60 | ||
|
|
e1839d4e8f | ||
|
|
ee73c8ad90 | ||
|
|
a9d58606a7 | ||
|
|
588095f370 | ||
|
|
9f5d9c0c11 | ||
|
|
e6a8799965 | ||
|
|
ff2e7ff6dc | ||
|
|
cd1e5841cf | ||
|
|
d318400504 | ||
|
|
9d70516da2 | ||
|
|
52e026d7e1 | ||
|
|
a811221228 | ||
|
|
3ffee8df78 | ||
|
|
63e3413141 | ||
|
|
b83b97934c | ||
|
|
6bf627e429 | ||
|
|
7ef8d8155f | ||
|
|
1aeb644e30 | ||
|
|
8913fa7c43 | ||
|
|
f42c2e4d04 | ||
|
|
0480b00766 | ||
|
|
26674e05e5 | ||
|
|
29eb39e545 | ||
|
|
fbcc7a317d | ||
|
|
ee07306a4d | ||
|
|
294f7a6cae | ||
|
|
595399385b | ||
|
|
77ee80cd63 | ||
|
|
9e67c597ef | ||
|
|
05ff6e8f2d | ||
|
|
07e43b5023 | ||
|
|
00e0dd42d1 | ||
|
|
6f60ade6df | ||
|
|
eaa067134e | ||
|
|
701c4360b4 | ||
|
|
e132d269eb | ||
|
|
79252d363d | ||
|
|
8ff21fa56b | ||
|
|
2305e44e68 | ||
|
|
439df56f2b | ||
|
|
f70f256c07 | ||
|
|
3b1f26cdf3 | ||
|
|
9965a13f40 | ||
|
|
fbafa54322 | ||
|
|
9607746912 | ||
|
|
785d33e289 | ||
|
|
a3eeb8b6d1 | ||
|
|
cae61116c8 | ||
|
|
ced0f61b87 | ||
|
|
9751c81574 | ||
|
|
550f195cf2 | ||
|
|
06c6cf7648 | ||
|
|
a5b6642b94 |
@@ -2,10 +2,6 @@ name: Build and Push Containers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- hotfix/*
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
@@ -52,7 +48,7 @@ jobs:
|
||||
if [ "${{ matrix.service }}" = "Lactose" ]; then
|
||||
echo "BUILD_ARGS=SixLaborsLicenseKey=${{ secrets.SIXLABORS_KEY }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BUILD_ARGS=" >> $GITHUB_ENV
|
||||
echo "BUILD_ARGS=APP_VERSION=$VERSION" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build and push ${{ matrix.service }}
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
# MilkyShots – Agent Guidelines
|
||||
|
||||
This file captures the coding conventions, architecture rules, and standards extracted from the codebase. Follow them in every contribution to keep the project consistent.
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
MilkyShots is a .NET 8 media-library application composed of four projects:
|
||||
|
||||
| Project | Role |
|
||||
|---|---|
|
||||
| **Butter** | Shared class library – DTOs, enums, MIME-type tables, settings definitions |
|
||||
| **Lactose** | ASP.NET Core Web API backend – controllers, EF Core models, repositories, services, background jobs |
|
||||
| **MilkStream** | Blazor WebAssembly host – serves static WASM files and provides dynamic configuration |
|
||||
| **MilkStream.Client** | Blazor WASM client – Razor components, frontend services, SCSS styles (runs in browser) |
|
||||
|
||||
The backend stores data in **PostgreSQL** (with the `pgvecto-rs` extension) accessed via **Entity Framework Core**.
|
||||
The WASM client runs entirely in the browser and communicates with the Lactose backend over HTTP/JSON using named `HttpClient` instances.
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **.NET 8 / C# 12** — target framework and language version for all projects.
|
||||
- **ASP.NET Core** (Lactose) and **Blazor WebAssembly** (MilkStream.Client hosted by MilkStream).
|
||||
- **Entity Framework Core** with `Npgsql.EntityFrameworkCore.PostgreSQL`.
|
||||
- **JWT Bearer** authentication + refresh-token rotation.
|
||||
- **Bootstrap 5** and **SCSS** (compiled by `AspNetCore.SassCompiler` in debug) for styling.
|
||||
- **Docker / docker-compose** for containerised deployment.
|
||||
|
||||
---
|
||||
|
||||
## Solution Structure
|
||||
|
||||
```
|
||||
MilkyShots/
|
||||
├── Butter/ # Shared library
|
||||
│ ├── Dtos/ # DTOs grouped by entity (Album/, Asset/, …)
|
||||
│ ├── Settings/ # Settings enum + extensions
|
||||
│ └── Types/ # Shared enums (EAccessLevel, EAssetType)
|
||||
├── Lactose/ # Web API
|
||||
│ ├── Authorization/ # Claims transformation + authorization handlers
|
||||
│ ├── Configuration/ # Options classes (SignKeyConfiguration, SignKeyProvider)
|
||||
│ ├── Context/ # EF Core DbContext (LactoseDbContext)
|
||||
│ ├── Controllers/ # API controllers
|
||||
│ ├── Jobs/ # Background job infrastructure + concrete jobs
|
||||
│ ├── Mapper/ # Model → DTO mapping (static extension methods)
|
||||
│ ├── Migrations/ # EF Core migrations (generated – do NOT hand-edit)
|
||||
│ ├── Models/ # EF Core entity classes
|
||||
│ ├── Repositories/ # Interfaces + implementations for data access
|
||||
│ ├── Services/ # Application services (auth, DB initialiser, scheduler)
|
||||
│ └── Utils/ # Small helpers / extension methods
|
||||
├── MilkStream/ # Blazor WASM static host
|
||||
│ ├── Program.cs # Minimal API – serves WASM files + dynamic /appsettings.json endpoint
|
||||
│ └── appsettings.json # Server-side config (LactoseBaseUrl for dynamic injection)
|
||||
└── MilkStream.Client/ # Blazor WASM client (runs in browser)
|
||||
├── Components/ # Razor components (Pages/, Layout/, SettingBoxes/, …)
|
||||
├── Services/ # Frontend services (LoginService, MediaService, …)
|
||||
├── Styles/ # SCSS source files
|
||||
└── wwwroot/ # Static assets bundled into WASM
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### C# identifiers
|
||||
|
||||
| Category | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Enums | `E` prefix | `EAccessLevel`, `EAssetType`, `EJobStatus` |
|
||||
| Interfaces | `I` prefix | `IAssetRepository`, `IAlbumRepository` |
|
||||
| DTOs | `…Dto` suffix (PascalCase) | `AssetDto`, `AlbumCreateDto` |
|
||||
| DTO sets: create / update / preview / full | `…CreateDto`, `…UpdateDto`, `…PreviewDto`, `…FullDto` | `AlbumCreateDto` |
|
||||
| Repository interfaces | `I…Repository` | `IAssetRepository` |
|
||||
| Repository implementations | `…Repository` | `AssetRepository` |
|
||||
| Mapper classes | `…Mapper` | `AssetsMapper`, `UsersMapper` |
|
||||
| Controller classes | `…Controller` | `AssetController` |
|
||||
| Background jobs | extend `Job` | `FileSystemCrawlJob` |
|
||||
| Services | `…Service` | `LactoseAuthService`, `LoginService` |
|
||||
|
||||
> **Note:** The suffix `DTO` (all-caps) appears in one legacy file (`PagedParametersDTO.cs`).
|
||||
> New files must use `Dto` (mixed-case) to match the rest of the codebase.
|
||||
|
||||
### Files and directories
|
||||
|
||||
- One public type per file; file name equals the type name.
|
||||
- Folders mirror the namespace suffix (e.g. `Lactose.Repositories` → `Lactose/Repositories/`).
|
||||
|
||||
---
|
||||
|
||||
## C# Coding Style
|
||||
|
||||
- Enable **nullable reference types** (`<Nullable>enable</Nullable>`) and **implicit usings** in all projects.
|
||||
- Prefer **primary constructors** (C# 12) for constructor injection:
|
||||
```csharp
|
||||
public class AssetController(
|
||||
ILogger<AssetController> logger,
|
||||
LactoseAuthService authService,
|
||||
IAssetRepository assetRepository
|
||||
) : ControllerBase { … }
|
||||
```
|
||||
- Use **`var`** for local variables when the type is obvious from the right-hand side.
|
||||
- Use **object initialisers** when creating entity or DTO instances.
|
||||
- Use the **`required`** keyword on properties that must be set during object construction.
|
||||
- Default string properties to `string.Empty`, not `null`.
|
||||
- Default collection navigation properties to `null` (lazy) — the EF Core model builder wires up relationships.
|
||||
- Mark navigation property regions with `#region Navigation Properties … #endregion`.
|
||||
|
||||
### Documentation comments
|
||||
|
||||
- Every **model class** and every property on it must have a `/// <summary>` XML comment.
|
||||
- Every **interface method** must have `<summary>`, `<param>`, and `<returns>` XML comments.
|
||||
- **Mapper classes** must have a `<summary>` on the class and on each method.
|
||||
- Controllers may omit XML comments on action methods (use inline comments instead).
|
||||
|
||||
### Logging
|
||||
|
||||
- Inject `ILogger<T>` into every controller and service.
|
||||
- Use structured logging with message templates (avoid string concatenation in log calls where possible).
|
||||
- Use verbatim interpolated string literals (`$"""…"""`) for multi-line log entries.
|
||||
- Log-level conventions:
|
||||
|
||||
| Level | When to use |
|
||||
|---|---|
|
||||
| `LogTrace` | Detailed per-request data (IDs, field values) |
|
||||
| `LogDebug` | Medium-detail operational info |
|
||||
| `LogInformation` | Key lifecycle events (job started, user registered, …) |
|
||||
| `LogWarning` | Non-fatal problems (missing resource, permission violation) |
|
||||
| `LogError` | Exceptions and failures |
|
||||
|
||||
---
|
||||
|
||||
## Entity / Model Rules
|
||||
|
||||
- Primary keys are **`Guid`** annotated with `[Key]`.
|
||||
- String columns must specify `[Column(TypeName = "VARCHAR(n)")]` with an appropriate max length.
|
||||
- Foreign-key scalar properties use `[ForeignKey(nameof(NavigationProperty))]`.
|
||||
- Timestamps follow the pattern: `CreatedAt`, `UpdatedAt`, `DeletedAt` (nullable `DateTime?` for soft delete).
|
||||
- **Soft delete** — entities are never hard-deleted from the database via normal application flows; set `DeletedAt = DateTime.Now` instead.
|
||||
- `[Index(nameof(Field), IsUnique = true)]` is used on fields that must be unique at the DB level.
|
||||
- Model classes live in `Lactose.Models`; shared enums/types live in `Butter.Types`.
|
||||
|
||||
---
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
- Every repository exposes its contract through an `I…Repository` interface in the same folder.
|
||||
- All repository interfaces implement `IDisposable` (or `IAsyncDisposable` where async teardown is needed).
|
||||
- Standard method set:
|
||||
|
||||
| Method | Signature |
|
||||
|---|---|
|
||||
| Find by PK | `T? Find(Guid id)` |
|
||||
| Find multiple | `IEnumerable<T> FindBulk(IEnumerable<Guid> ids)` |
|
||||
| Insert | `void Insert(T entity)` |
|
||||
| Update | `void Update(T entity)` (sets `UpdatedAt = DateTime.Now`) |
|
||||
| Bulk update | `void UpdateBulk(IEnumerable<T> entities)` |
|
||||
| Save | `void Save()` → `context.SaveChanges()` |
|
||||
| Delete | `void Delete(T entity)` or `void Delete(Guid id)` |
|
||||
|
||||
- **`Save()` must be called explicitly** after insert/update/delete operations; repositories do not auto-save except `FolderRepository.Create()` (which needs to fire an event after commit).
|
||||
- Repositories may expose **static events** for cross-service notification (e.g. `FolderRepository.FolderAdded`, `SettingsRepository.SettingChanged`). Keep these static and `EventHandler<T>` typed.
|
||||
- Register all repositories as **`Transient`** in `Program.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Mapper / DTO Rules
|
||||
|
||||
- Mapper classes are **`public static`** and live in `Lactose.Mapper`.
|
||||
- Mapping methods are **extension methods** on the model type:
|
||||
```csharp
|
||||
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) { … }
|
||||
```
|
||||
- Naming convention: `To<TargetType>()` or `To<TargetType>(extraParam)`.
|
||||
- Never put business logic in mappers — only field assignments.
|
||||
|
||||
---
|
||||
|
||||
## Controller Rules
|
||||
|
||||
- Decorate every controller with `[ApiController]` and `[Route("api/[controller]")]`.
|
||||
- Route URLs are lowercase (`builder.Services.Configure<RouteOptions>(o => o.LowercaseUrls = true)`).
|
||||
- HTTP verb → CRUD mapping:
|
||||
|
||||
| Verb | Operation |
|
||||
|---|---|
|
||||
| `[HttpGet]` / `[HttpGet("{id}")]` | Read single / search/list |
|
||||
| `[HttpPut]` | Create a new resource |
|
||||
| `[HttpPost("{id}")]` | Update a specific resource |
|
||||
| `[HttpPost]` | Bulk update |
|
||||
| `[HttpDelete("{id}")]` | Delete a specific resource |
|
||||
| `[HttpDelete]` | Bulk delete |
|
||||
|
||||
- Return `ActionResult<T>` for endpoints that return a body, and `IStatusCodeActionResult` or `ActionResult` for status-only responses.
|
||||
- Standard error returns: `NotFound()`, `BadRequest()`, `Unauthorized()`, `Forbid()`, `Conflict(…)`.
|
||||
- Always validate `null` after a repository lookup and return `NotFound()` before proceeding.
|
||||
- Access-level checks must be performed inside the action method as well as via `[Authorize]` attributes (defence-in-depth).
|
||||
|
||||
---
|
||||
|
||||
## Authentication & Authorisation
|
||||
|
||||
- Auth uses **JWT Bearer** tokens (`JwtBearerDefaults.AuthenticationScheme`).
|
||||
- Access is controlled by three roles (in ascending privilege order):
|
||||
|
||||
| Role | Description |
|
||||
|---|---|
|
||||
| `User` | Read-only access to publicly shared / explicitly shared content |
|
||||
| `Curator` | Can create/update/delete their own content |
|
||||
| `Admin` | Unrestricted access |
|
||||
|
||||
- `[Authorize]` with no roles = any authenticated user.
|
||||
- `[Authorize(Roles = "Admin")]` or `[Authorize(Roles = "Admin, Curator")]` for restricted endpoints.
|
||||
Note the space after the comma in the role list — this is the project's established style.
|
||||
- Use `LactoseAuthService.GetUserData(User)` inside action methods to retrieve the typed `LactoseAuthenticatedUser` from claims.
|
||||
- Refresh tokens are stored (hashed by ASP.NET Identity) in the `User` table.
|
||||
`RefreshTokenTransformation` (a `IClaimsTransformation`) validates the refresh token on every request and adds a `"LoginValid"` claim.
|
||||
- Access tokens expire after **10 minutes**; refresh tokens expire after **60 minutes**.
|
||||
|
||||
---
|
||||
|
||||
## Background Jobs System
|
||||
|
||||
- All background jobs **extend `Job`** (abstract base in `Lactose.Jobs`).
|
||||
- Override `TaskJob(CancellationToken token)` to implement job logic.
|
||||
- Check `token.IsCancellationRequested` inside loops and call `Status.Cancel()` + `return` when set.
|
||||
- Use `Status.UpdateProgress(float, string)` to report progress (value between 0 and 1).
|
||||
- Use `Status.Complete(string)` / `Status.Fail(string)` for terminal states.
|
||||
- New job types must be instantiatable via `ActivatorUtilities.CreateInstance<T>(serviceProvider, extraArgs)`.
|
||||
- Enqueue jobs with `JobManager.CreateJob<T>(args)` then `JobManager.EnqueueJob(job)`.
|
||||
- Maximum concurrent jobs defaults to **8** (`JobManager.MaxConcurrentJobs`).
|
||||
- Timer-based scheduling is handled by `JobScheduler` which reacts to `SettingsRepository.SettingChanged`.
|
||||
|
||||
---
|
||||
|
||||
## Settings System
|
||||
|
||||
- Runtime settings are persisted in the `Settings` database table.
|
||||
- Canonical names are defined in `Butter.Settings.Settings` enum with `AsString()` extension.
|
||||
- Default values and metadata are loaded from **`Lactose/DefaultSettings.json`** on startup by `DbInitializer`.
|
||||
- When adding a new setting:
|
||||
1. Add an entry to `Settings` enum and `AsString()`.
|
||||
2. Add the default entry to `DefaultSettings.json`.
|
||||
3. React to changes via `SettingsRepository.SettingChanged` event.
|
||||
|
||||
---
|
||||
|
||||
## Frontend (MilkStream.Client / Blazor WASM)
|
||||
|
||||
- Use **Blazor WebAssembly** – the client runs entirely in the browser.
|
||||
- Pages live in `MilkStream.Client/Components/Pages/`, reusable components in `MilkStream.Client/Components/` (or a subfolder by concern).
|
||||
- Services that call the backend extend either `ServiceBase` (no auth) or `AuthServiceBase` (requires auth).
|
||||
- `AuthServiceBase` subscribes to `LoginService.AuthInfoChanged` to keep the `Authorization` header up to date.
|
||||
- Use the **`"MilkStreamClient"`** named `HttpClient` (configured with `JwtTokenRefresher`) for any authenticated API call.
|
||||
- `JwtTokenRefresher` automatically refreshes the access token when it is expired or expires within 1 minute.
|
||||
- Use `ProtectedLocalStorage` / `ProtectedSessionStorage` (Blazor's encrypted storage) for auth-related persistence.
|
||||
|
||||
### Dynamic Configuration
|
||||
- MilkStream's `Program.cs` registers a `GET /appsettings.json` endpoint **before** `UseStaticFiles()`, shadowing the static file.
|
||||
- This endpoint returns `{ "BaseUrl": "<LactoseBaseUrl>" }` from server-side configuration, ensuring the WASM client always receives the externally-reachable Lactose URL.
|
||||
- The `LactoseBaseUrl` setting is injected via environment variable in Docker deployments and configured in `appsettings.json` / `appsettings.Development.json` for local development.
|
||||
|
||||
- SCSS source files go in `MilkStream.Client/Styles/`; compiled output is written to `MilkStream.Client/wwwroot/css/` (do not edit the compiled files by hand).
|
||||
|
||||
---
|
||||
|
||||
## Docker / Deployment
|
||||
|
||||
- Each deployable project has its own **multi-stage `Dockerfile`** (build → publish → base).
|
||||
- `docker-compose.yml` at repo root wires up `lactose`, `milkstream`, and `database` services.
|
||||
- Media is mounted as a **read-only volume** at `/diary` inside the `lactose` container.
|
||||
- Data-protection keys for MilkStream are persisted via a named Docker volume.
|
||||
- Environment-specific config is loaded from `appsettings.{EnvironmentName}.json`; secrets are injected via environment variables or Docker secrets — **never commit real credentials**.
|
||||
- When `CorsAllowedOrigins` is set to `["*"]` in Lactose configuration, the API accepts requests from any origin (useful for browser-based WASM clients in unpredictable deployment environments). Lock this down to specific origins in production if needed.
|
||||
|
||||
---
|
||||
|
||||
## Configuration & Secrets
|
||||
|
||||
- `appsettings.json` contains placeholder/default values only.
|
||||
- Sensitive values (DB password, JWT signing key) must be provided via environment variables or .NET User Secrets (development only, keyed by `UserSecretsId`).
|
||||
- The JWT signing key (`SignKey:Key`) must be **at least 32 characters**.
|
||||
- The DB port mapping in `docker-compose.yml` exposes PostgreSQL on host port `3306` (maps to container port `5432`).
|
||||
|
||||
---
|
||||
|
||||
## Git & Commit Conventions
|
||||
|
||||
This project uses **Conventional Commits** and generates a changelog with **git-cliff**.
|
||||
|
||||
| Prefix | Purpose |
|
||||
|---|---|
|
||||
| `feat:` | New feature |
|
||||
| `fix:` | Bug fix |
|
||||
| `refactor:` | Code restructuring without behaviour change |
|
||||
| `perf:` | Performance improvement |
|
||||
| `doc:` | Documentation changes |
|
||||
| `style:` | Formatting / style only |
|
||||
| `test:` | Tests |
|
||||
| `chore:` / `ci:` | Build system, tooling, CI |
|
||||
| `revert:` | Revert a previous commit |
|
||||
|
||||
- Commits with `chore(release):`, `chore(deps)`, `chore(pr)`, `chore(pull)` are excluded from the changelog automatically.
|
||||
- Breaking changes must be marked with `[**breaking**]` in the commit body or footer.
|
||||
|
||||
---
|
||||
|
||||
## TODOs & Known Gaps (as of initial analysis)
|
||||
|
||||
The following items appear as `TODO` comments in the codebase and should be tracked:
|
||||
|
||||
- `AssetController.Create` — endpoint marked `[NonAction]`; purpose unclear, may need removal or redesign.
|
||||
- `AlbumController`, `FolderController`, `SettingsController`, `TagController` — logging not yet wired in.
|
||||
- `AuthController.Register` — should return a full `AuthResultDto` instead of a bare Guid.
|
||||
- `UserController.Get` / `GetAll` — lower access levels should receive reduced user information.
|
||||
- `FileSystemCrawlJob.AssetFromPath` — folder ID is not populated when crawling.
|
||||
- `TagController.Get` (ancestors traversal) — not protected against circular references.
|
||||
- Self-registration flow in `AuthController` is gated by the `UserRegistrationEnabled` setting but that check is not yet implemented in the controller.
|
||||
+3
-1
@@ -9,4 +9,6 @@ dotnet-tools.json
|
||||
.opencode/
|
||||
*.css.map
|
||||
# SixLabors license file
|
||||
/Lactose/sixlabors.lic
|
||||
/Lactose/sixlabors.lic
|
||||
.desloppify/
|
||||
.claude/
|
||||
@@ -20,6 +20,8 @@
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
<method v="2">
|
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -29,6 +29,8 @@
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
<method v="2">
|
||||
<option name="RunConfigurationTask" enabled="false" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,17 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="VersionEnvGen" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/generate-version.sh" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||
<option name="INTERPRETER_PATH" value="/bin/zsh" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="true" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -44,8 +44,6 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
||||
|
||||
**Database:** Default credentials in `appsettings.json`. Docker compose spins up all services.
|
||||
|
||||
**Port gotcha:** `appsettings.json` defaults `DatabaseAddress:Port` to **3306** (legacy MySQL port). PostgreSQL runs on 5432. The raw connection string must be overridden, or use `docker-compose.yml` which maps host 3306 → container 5432 automatically.
|
||||
|
||||
## Infrastructure gotchas
|
||||
|
||||
- **MilkStream `/appsettings.json`:** Dynamically generated from server config — maps **before** `UseStaticFiles`, so it shadows the static file in `MilkStream.Client/wwwroot/`. This endpoint provides `LactoseBaseUrl` to the WASM client.
|
||||
|
||||
@@ -27,6 +27,14 @@ public class AlbumPreviewDto {
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover image width, if a cover exists.
|
||||
/// </summary>
|
||||
public int? CoverWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover image height, if a cover exists.
|
||||
/// </summary>
|
||||
public int? CoverHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets in the album.
|
||||
/// </summary>
|
||||
public int AssetCount { get; set; }
|
||||
|
||||
@@ -14,4 +14,9 @@ public class AlbumSearchParametersDto : PagedSearchParametersDto {
|
||||
/// Only returns albums that contain at least one asset uploaded by the specified user.
|
||||
/// </summary>
|
||||
public Guid? AssetUploadedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID to filter albums by their owner.
|
||||
/// </summary>
|
||||
public Guid? PersonOwnerId { get; set; }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,9 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
|
||||
/// Gets or sets the uploader user ID to filter assets by their uploader.
|
||||
/// </summary>
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the total matching count should be computed and returned in the <c>X-Total-Count</c> header.
|
||||
/// Skipping it avoids a full count query over the filtered result set.
|
||||
/// </summary>
|
||||
public bool IncludeCount { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public enum Settings {
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="Settings"/> enum.
|
||||
/// </summary>
|
||||
public static class SettingsExtensions {
|
||||
public static class SettingsExtension {
|
||||
/// <summary>
|
||||
/// Converts a <see cref="Settings"/> value to its human-readable display string.
|
||||
/// </summary>
|
||||
|
||||
+859
@@ -0,0 +1,859 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0.3.2] - 2026-08-17
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add personOwnerId album filter and opt-in asset IncludeCount
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Page cosplayer albums via the album endpoint and skip count queries
|
||||
- Cache thumbnails and previews in the browser for 24h
|
||||
- Add filtered IX_Assets_VisibleCreatedAt index for the default listing
|
||||
- Server-side seeded shuffle, DTO projection, and opt-in count
|
||||
- Project album assets to DTO in SQL and slim the Find paths
|
||||
- Push album filter/search/sort/pagination to SQL in person FindVisible
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Add auto-save middleware as a safety net for the Save() pattern
|
||||
- Desloppify review fixes: PathFromGuid extraction, RegexHighlighter dedup, port default fix
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- Quality improvements from the desloppify scan
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Update desloppify scorecard and add it to the README
|
||||
|
||||
## [1.0.3.1] - 2026-07-22
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add sliding-window page buffer to AlbumGrid and CosplayerGrid with scroll-based observer reattach
|
||||
- Embed git version in WASM footer via AssemblyInformationalVersion
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Use status CSS variables for progress bar segment colors
|
||||
- Eliminate masonry layout race on infinite scroll and banner double-fetch (#146)
|
||||
- Masonry items stacking on infinite scroll — missing @ on ViewMode binding (#146)
|
||||
- Prevent infinite scroll cascade by debouncing observer reattach and disabling scroll anchoring
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- Replace IntersectionObserver with input-event-based infinite scroll
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Scripts and run configs to automate version injection
|
||||
- Removed outdated agent.md
|
||||
|
||||
## [1.0.3] - 2026-07-21
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Cropper handle size constant mismatch (14→18 px)
|
||||
- Jobs past tab zero-based pagination
|
||||
- Show registration disabled message on 403
|
||||
- Hide register form when registration is disabled
|
||||
- Allow manual folder scan when scheduled scan is off
|
||||
- Prevent masonry layout shift on infinite scroll
|
||||
- Order album assets by CreatedAt in album detail (#144)
|
||||
- Replace overflow-y with JS masonry for stable album layout (#146)
|
||||
- Add aspect-ratio to album cards for stable masonry height (#146)
|
||||
- Use per-card aspect-ratio from cover dimensions for stable masonry layout (#146)
|
||||
- Restore masonryLayout.dispose that was lost in reset edit
|
||||
- Use pre-pagination album count (#143)
|
||||
- Sort album assets by OriginalFilename instead of CreatedAt (#144)
|
||||
|
||||
## [1.0.2] - 2026-07-17
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Remove redundant AddJsonFile calls that overrode env vars
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Auto-run on tags only, manual dispatch on branches
|
||||
|
||||
## [1.0.1] - 2026-07-17
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Use explicit domain git.r3d.codes instead of gitea.server_url
|
||||
- Sanitize tag names for Docker compatibility (replace / with -)
|
||||
- Lowercase repository name for Docker compatibility
|
||||
- Match matrix service names to actual directory case (Lactose/MilkStream)
|
||||
- Remove gha cache config (cache server unreachable from builder)
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Add BuildKit cache mounts for NuGet packages in Dockerfiles
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Add workflow_dispatch trigger with version input
|
||||
- Also run on push to master
|
||||
- Also run on develop and hotfix/* branches
|
||||
- Pass SixLaborsLicenseKey as build arg for lactose
|
||||
- Use REGISTRY_TOKEN (PAT) instead of GITEA_TOKEN for registry push
|
||||
|
||||
### CI
|
||||
|
||||
- Added sixlabors license key to the docker file for lactose
|
||||
|
||||
## [1.0] - 2026-07-16
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(API)* Adds Authentication System (Needs Testing)
|
||||
- *(API)* Adds UserRepository.cs
|
||||
- *(API)* Adds Password hashing
|
||||
- *(API)* Adds Password hashing
|
||||
- *(API)* Implements user delete endpoint
|
||||
- *(API)* Adds stubs for AssetController.cs
|
||||
- *(API)* Adds Album endpoint Stubs
|
||||
- *(API)* Adds TagController class
|
||||
- *(API)* Adds Tag endpoint implementation
|
||||
- *(API)* Adds Repository implementation (not tested yet)
|
||||
- *(API)* Implements TagController With Bulk Requests
|
||||
- *(API)* Adds person stub endpoint
|
||||
- *(API)* Added Resolution, mimetype and extra media data on asset table
|
||||
- *(API)* Adds computed file hash field in Asset
|
||||
- *(API)* Adds Folder and Asset entities in the DB
|
||||
- *(API)* Implemented FolderController and their respective Mappers/DTO/Repository interface and it's own implementation
|
||||
- [**breaking**] Upgraded project to .Net 9.0
|
||||
- *(API)* Implemented Settings Endpoint
|
||||
- *(API)* Implemented Media endpoint and relative Repository and DTO
|
||||
- *(DB)* Switched to PostgreSQL connector
|
||||
- *(DB)* [**breaking**] Change to use database pgvecto-rs
|
||||
- *(DB)* [**breaking**] Regenerated migrations for PostgreSQL switch
|
||||
- Switched to on the fly connection string generation
|
||||
- *(DbContext)* Adds Initialization for Development environment
|
||||
- *(API)* Adds Authentication Result as DTO for authentication
|
||||
- *(Lactose)* Adds RefreshToken
|
||||
- *(Lactose)* Adds DB Context Snapshot
|
||||
- *(Lactose)* Adds RefreshToken Endpoint
|
||||
- *(DB)* Changed Asset table to have OriginalPath as Unique Index
|
||||
- Added a settings enum + it's own extension class to retrieve interpretation type and string name in DB
|
||||
- *(db)* Adds Migration for Settings in LactoseDbContext
|
||||
- Added disposability to some repositories, changed the FileSystemScanner service to be a singleton.
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Upgraded login to new API changes
|
||||
- *(API)* Added get-all endpoint for settings controller.
|
||||
- *(frontend)* Added settings service
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Added login and JWT token auto-refresh
|
||||
- *(frontend)* Adds ServiceBase and AuthServiceBase abstract classes
|
||||
- *(frontend)* Login and logout now properly work
|
||||
- *(frontend)* Added folders service
|
||||
- *(frontend)* Nav menu restyle
|
||||
- *(API)* Adds method to modify the FileWatcherService: folders, time
|
||||
- *(API)* Edits SettingsRepository to manage FileWatcher Service
|
||||
- *(frontend)* Editing of settings and addition/editing of folder paths
|
||||
- *(frontend)* Added folder removal in settings
|
||||
- *(API)* Added get-all endpoint for settings controller.
|
||||
- *(frontend)* Added settings service
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Added login and JWT token auto-refresh
|
||||
- *(frontend)* Adds ServiceBase and AuthServiceBase abstract classes
|
||||
- *(frontend)* Login and logout now properly work
|
||||
- *(frontend)* Added folders service
|
||||
- *(frontend)* Nav menu restyle
|
||||
- *(API)* Adds method to modify the FileWatcherService: folders, time
|
||||
- *(API)* Edits SettingsRepository to manage FileWatcher Service
|
||||
- *(frontend)* Editing of settings and addition/editing of folder paths
|
||||
- *(frontend)* Added folder removal in settings
|
||||
- Migrate MilkStream frontend from Blazor SSR to Blazor WASM
|
||||
- *(cors)* Support semicolon-separated CorsAllowedOrigins env var
|
||||
- Add ParentJobId to JobStatusDto for job hierarchy tracking
|
||||
- Add ParentJobId to Job base class
|
||||
- Set ParentJobId on child jobs for ThumbnailJob, FileSystemCrawlJob, PHashJob
|
||||
- Map ParentJobId in JobMapper
|
||||
- Add generic reusable components ProgressBar, CollapsibleSection, PollRateSelector
|
||||
- Add JobRow, JobChildrenSummary, JobSection, JobTree components
|
||||
- Redesign Jobs page with compact hierarchical layout, poll rate selector, and PHash button; remove JobCard
|
||||
- Optimize jobs API with pagination, DB persistence, and cleanup
|
||||
- Add GetAssetsMissingMetadata repository method for metadata job
|
||||
- Implement MetadataJob for image resolution extraction
|
||||
- *(docker)* Add named volumes for thumbnails and previews
|
||||
- Unify settings pages into sidebar + detail panel with auto-save
|
||||
- *(assets)* Add ThumbnailSize column to track generated thumbnail dimensions
|
||||
- *(repository)* Add GetAssetsMissingOrWrongThumbnail to interface
|
||||
- *(repository)* Implement GetAssetsMissingOrWrongThumbnail query
|
||||
- *(thumbs)* Read ThumbnailSize setting, invalidate wrong-sized thumbnails, set size after gen
|
||||
- *(db)* Add migration for ThumbnailSize column on Assets
|
||||
- *(jobs)* Add CompletedWithErrors status for parents with failed sub-jobs
|
||||
- *(ui)* Show CompletedWithErrors as orange and group sub-jobs by status
|
||||
- *(jobs)* Apply batching to PHashJob and MetadataJob
|
||||
- *(settings)* Add JobBatchSize setting for batch job sizing
|
||||
- *(settings)* Add JobBatchSize setting for batch job sizing
|
||||
- *(settings)* Add runtime-configurable Po2W slider limits + split thumbnail/preview ranges
|
||||
- *(folders)* Add regex pattern field with named groups, syntax highlighting, and sample path preview
|
||||
- Masonry gallery on homepage with infinite scroll
|
||||
- Pass JWT token in media URLs for authenticated thumbnail/preview requests
|
||||
- Make navbar sticky during infinite scroll
|
||||
- Add PreviewJob and upgrade thumbnails to WebP
|
||||
- Add CreatePersons and CreateAlbums jobs
|
||||
- Add stacked progress bar for parent jobs with sub-job breakdown
|
||||
- Improve logging and progress reporting for album/person jobs
|
||||
- Add statistics page with aggregate asset/user/system stats via fast DB queries
|
||||
- Add thousands separators to job status numbers
|
||||
- Extend AssetPreviewDto with album and cosplayer names
|
||||
- Add cosplayer and album name overlays on home page
|
||||
- Add full-size view and download buttons in preview overlay
|
||||
- Add bidirectional infinite scroll (up and down)
|
||||
- Implement seed-based deterministic random ordering for home page
|
||||
- Add data completeness indicators to statistics page
|
||||
- Add CoverAssetId to albums with resolution data in full DTO
|
||||
- Add AlbumService and PersonService to WASM client
|
||||
- Add albums list page and album detail page with inline preview
|
||||
- Auto-assign album cover from first alphabetically-sorted asset
|
||||
- Make album names clickable links in home tile hover and preview
|
||||
- Justified gallery layout for album detail page
|
||||
- View mode selector for albums page (masonry/grid/list)
|
||||
- *(admin)* Add user management page for admins
|
||||
- *(admin)* Add inline editing for username, email, and password
|
||||
- *(stats)* Add percentage and stale counters to completeness cards
|
||||
- *(backend)* Add ProfileAssetId to Person model + migration
|
||||
- *(dtos)* Update Person DTOs with ProfileAssetId and album previews
|
||||
- *(backend)* Eager-load ProfileAsset and Albums in PersonRepository
|
||||
- *(backend)* Add PersonMapper with ToPersonPreviewDto and ToPersonDetailedDto
|
||||
- *(backend)* Implement person detail, update, and delete endpoints
|
||||
- *(client)* Extract AlbumCard reusable component, update Albums.razor
|
||||
- *(client)* Add PersonService client methods and PersonForm component
|
||||
- *(client)* Add Cosplayers and CosplayerDetail pages, fix NavMenu, add modularity docs
|
||||
- *(css)* Add Cosplayers and CosplayerDetail scoped CSS with banner, grid, and card styles
|
||||
- *(backend)* Add ProfileCropX/Y to Person model for profile picture cropping
|
||||
- *(ui)* Add circular profile picture cropper with drag-to-pan
|
||||
- *(ui)* Redesigned profile cropper with movable circle, dimmed exterior, square boundary, zoom controls; added ProfileCropZoom to model; switched avatars to background-image for zoom support
|
||||
- *(ui)* Add New Cosplayer button on list page with create endpoint and form
|
||||
- *(ui)* Unassigned album selector in PersonForm, Add Albums button in CosplayerDetail
|
||||
- *(dto)* Add RemovePerson flag to AlbumUpdateDto for explicit person unlinking
|
||||
- Person hard delete with album cascade, AlbumCard select mode, cosplayer list multi-select
|
||||
- Infinite scroll for albums on cosplayer detail page
|
||||
- Migrate to UTC timestamps — remove legacy Npgsql timestamp behavior
|
||||
- Make album name in image previewer an anchor element for native navigation
|
||||
- Allow users to update own albums and curators to update any album
|
||||
- Add OldPassword field to UserUpdateDto for self-service password change
|
||||
- Enforce old-password verification on own password change, allow admin override
|
||||
- Add password change UI with strength bar and validation on user profile page
|
||||
- Add server-side password policy validation (admin bypass)
|
||||
- *(ui)* Redesign user profile page with avatar initials and edit profile modal
|
||||
- *(ui)* Rewrite admin users page with sortable table, search, and modal-based editing
|
||||
- *(ui)* Show avatar initial in navbar instead of generic icon
|
||||
- *(ui)* Album detail multi-select edit mode, cosplayer link, move edit button (#63)
|
||||
- *(api)* Add BulkDeleteAlbumsAsync for bulk album deletion
|
||||
- *(ui)* Add album select/edit mode with bulk deletion
|
||||
- *(ui)* Redesign jobs page with tabs, search, filters, and mobile cards
|
||||
- Add LastChange tracking to JobStatus
|
||||
- Add ModifiedAt to JobRecord with delta query support
|
||||
- Implement delta-based children endpoint with ?since parameter
|
||||
- Add delta merge logic to job tree components
|
||||
- Poll children for all active roots and fix segment progress bar colors
|
||||
- Return-url login redirects + keepalive + cosplayers fix
|
||||
- Add password strength validation to registration page
|
||||
- Hide cosplayers with no visible content from regular users
|
||||
- *(css)* SCSS foundation with Bootstrap 5.3.7 source, new dark navy theme, Inter font
|
||||
- *(ui)* CSS variables for .razor.css, job type colors, interactive states, gradients
|
||||
- *(jobs)* Extract all job-type colors to configurable SCSS variables
|
||||
- *(ui)* CosplayerDetail banner empty gradient uses accent theme
|
||||
- *(ui)* Aggressive gradients on navbar, buttons, modal headers
|
||||
- *(ui)* Page background gradient, status/strength CSS vars, nav/image polish
|
||||
- *(ui)* Replace default favicon with MilkyShot logo SVG
|
||||
- *(cropper)* Circle handles with accent color, black border, drop shadow
|
||||
- *(gradients)* Strengthen all gradients for more aggressive effect
|
||||
- *(ui)* Apply metallic gradient to all buttons and surfaces
|
||||
- *(ui)* Add eye toggle button to show/hide password on all password fields
|
||||
- *(api)* Add sort parameters to pagination DTOs
|
||||
- *(api)* Add sorting and unassigned filter to AlbumRepository.SearchQuery
|
||||
- *(api)* Pass sort and filter params through AlbumController.Search
|
||||
- *(ui)* Add SortFilterBar component and update Albums page with search/sort
|
||||
- *(ui)* Extract AlbumGrid to isolate grid re-renders from SortFilterBar
|
||||
- *(api)* Add SearchQuery to PersonRepository with sort/search/pagination
|
||||
- *(api)* Paginate PersonController.GetAll with sort/search params
|
||||
- *(ui)* Convert Cosplayers page to infinite scroll with sort/search toolbar
|
||||
- *(ui)* Extract CosplayerGrid to isolate grid re-renders from SortFilterBar
|
||||
- Add SearchDropdown typeahead component
|
||||
- Wire SearchDropdown into NavMenu
|
||||
- Make search dropdown headers selectable with keyboard and mouse
|
||||
- Parse ?search= query param on Cosplayers and Albums pages
|
||||
- *(cosplayer-detail)* Add SortFilterBar for album search and sort
|
||||
- *(butter)* Add EVisibility enum, Maintainer access level, and SystemUploaderId setting
|
||||
- *(butter)* Update DTOs for visibility, renamed Owner→UploadedBy, removed IsPublic/Owner fields
|
||||
- *(models)* Update models for auth redesign — rename Owner→UploadedBy, drop UserOwnerId/IsPubliclyShared, add EVisibility, PersonMaintainer
|
||||
- *(infra)* Update DbContext and mappers for auth redesign — new relationships, PersonMaintainer, conditional Visibility
|
||||
- *(repos,controllers)* Update repositories, interfaces, controllers for auth redesign — visibility filtering, UploadedBy/Uploader rename, Maintainer support
|
||||
- *(db)* Add migration for auth redesign — drop IsPubliclyShared/UserOwnerId/SharedWith, add Visibility, PersonMaintainer, rename OwnerId→UploadedBy
|
||||
- *(auth)* Complete Maintainer role — add PersonMaintainer checks, update controller authz, pass uploadedBy search param
|
||||
- Add MaintainedPersonIds to UserInfoDto with backend plumbing
|
||||
- Add Maintainer to admin user dropdowns and role badges
|
||||
- Add Visibility dropdown to album and person create/edit forms
|
||||
- Add Protected Assets card to stats page
|
||||
- Expand UI gating to include Maintainer with scope-aware CanEdit
|
||||
- Maintainer cosplayer assignment in admin user edit modal
|
||||
- Maintainer assignment UI for cosplayers, maintainer display on user page
|
||||
- Expand user page edit modal for admins with access level, maintainer assignment, and danger zone
|
||||
- Make usernames clickable on admin users page, linking to /User/{id}
|
||||
- Add docker-compose.debug.yml and UseWebAssemblyDebugging for WASM debugging
|
||||
- Add role-visible colored borders on albums and assets based on item state (#78)
|
||||
- Add bulk delete and visibility change to AlbumDetail select mode
|
||||
- Show visibility/deleted breakdown in album header in select mode
|
||||
- Add Select All / Deselect All button in album select mode
|
||||
- Add album-level bulk actions to CosplayerDetail page
|
||||
- Cascade visibility to albums and assets with lower-only rule
|
||||
- Add multiselect editing controls to Cosplayers page
|
||||
- Wire up SystemUploaderId setting — seed, UI, and crawl job backfill
|
||||
- Bulk cosplayer assignment for albums from multiselect mode
|
||||
- Add reusable ConfirmDialog component
|
||||
- Add visibility-colored borders to cosplayer cards in selection mode
|
||||
- Add rate limiting with configurable buckets
|
||||
- Apply rate-limit buckets to controllers
|
||||
- Add AlbumMergeDto and PersonMergeDto
|
||||
- Add MergeAlbums to album repository
|
||||
- Add MergePeople to person repository
|
||||
- Add merge endpoints to AlbumController and PersonController
|
||||
- Add frontend service methods for merge, bulk update, and bulk delete people
|
||||
- Add MergeModal shared component for merge destination selection
|
||||
- Add Merge button to Albums.razor with AlbumGrid preview access
|
||||
- Add Merge button to Cosplayers.razor with CosplayerGrid preview access
|
||||
- Add Merge Albums button to CosplayerDetail.razor
|
||||
- Add Unlink cosplayer button to AlbumDetail.razor
|
||||
- Add UnlinkedAssetGroupDto and extend AssetSearchOptionsDto with unlinked/folder/uploader filters
|
||||
- Add trigram GIN index on Asset.OriginalFilename for efficient ILike search
|
||||
- Extend AssetRepository with unassigned, folderId, uploadedBy, search filters and GetUnlinkedGroups
|
||||
- Extend AssetController with unlinked-groups endpoint and pass new search/unassigned/folder/uploader params
|
||||
- Extend AssetService with unassigned/folder/uploader/search params and GetUnlinkedGroupsAsync
|
||||
- Add AlbumAssetPicker modal component with drillable folder sidebar, search, and multi-select
|
||||
- Integrate AlbumAssetPicker into AlbumDetail with Add Assets button and merge handler
|
||||
- Add file-browser BrowseUnlinkedAssets endpoint with directory tree from OriginalPath
|
||||
- Redesign AlbumAssetPicker with breadcrumb navigation, directory drill-down, and file browser UX
|
||||
- Add collapsible sidebar toggle to AlbumAssetPicker folder browser
|
||||
- Add list/grid view toggle, bulk delete in maintenance mode, and /maintenance page
|
||||
- Add visibility and create-album actions, fix embedded viewport sizing
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(API)* Split authentication from user endpoint
|
||||
- *(API)* Fix retrieve of user authentication data
|
||||
- *(API)* Split authentication from user endpoint
|
||||
- *(API)* Fixes null exception against anonymous users requests
|
||||
- *(API)* Fix exception for UserRepository.cs
|
||||
- *(API)* Fix Password hasher dependency resolver
|
||||
- *(API)* Adds last login update on login
|
||||
- *(API)* Fixes null exception against anonymous users requests
|
||||
- *(API)* Fix exception for UserRepository.cs
|
||||
- *(API)* Fix Password hasher dependency resolver
|
||||
- *(API)* Adds last login update on login
|
||||
- *(API)* Remove login permission to Banned or Disabled Users
|
||||
- *(API)* User endpoints to be in line with new structure
|
||||
- *(API)* Switches tagSearch method from uint to int
|
||||
- *(docker)* Fixed Dockerfile stuck on old project name and build configs
|
||||
- *(API)* Added support for pagination on Asset GetAll Query
|
||||
- *(API)* Added deleted at field in UserInfo mappers
|
||||
- *(API)* Implements GetAll tags in tag Repository
|
||||
- *(API)* Wrong logger in album controller
|
||||
- *(API)* Standardized DTO namespaces
|
||||
- *(API)* Added required instead of null initialized strings where it didn't make sense to have a null string in Users DTOs
|
||||
- *(API)* Added missing Email field in UserInfoDto
|
||||
- *(API)* Marked as required both fields of BulkDto
|
||||
- *(API)* Marked as required both fields of credentials DTOs
|
||||
- *(API)* Marked as required the tag name in TagCreateDto
|
||||
- *(API)* Marked as required all the various required fields in person related DTOs
|
||||
- *(API)* Added null checking to folderRepository get operations in FolderController
|
||||
- *(API)* Added GetAll method to Folder Repository and controller
|
||||
- *(API)* [**breaking**] Marked required properties in Asset as required
|
||||
- *(API)* Added FindByPath in AssetRepository
|
||||
- *(API)* Allow Anonymous to use MediaEndpoint
|
||||
- *(docker)* Dockerfiles updated to use Net 8 as base images
|
||||
- *(docker)* Solved mismatch with base folders in both dockerfile and compose file
|
||||
- *(docker)* Applied "Docker" as environment when building the application in a container thus avoiding not getting the correct configuration overloads at run time.
|
||||
- *(Docker)* Removes Env var from where is not needed and add it to teh compose
|
||||
- *(Docker)* Add internal db port in appsettings.Development.json
|
||||
- *(docker)* [**breaking**] Postgres volume data was not named properly
|
||||
- *(db)* Added postgres switch for Legacy Timestamp behaviour
|
||||
- *(API)* Correct reference to the return type of result
|
||||
- *(API)* Fixes the Db Initializer on Production Environment
|
||||
- *(DB)* Added check for already existing assets on DB entry.
|
||||
- *(Services)* Added FileSystem scan service to the startup of the webserver.
|
||||
- *(frontend)* Adds the new services to the app startup configuration
|
||||
- *(frontend)* Added correct headers and response format to LoginService
|
||||
- *(frontend)* Adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object.
|
||||
- *(API)* Added SaveChanges to Settings repository, reflecting changes to he database
|
||||
- *(API)* Solved filesystem scanner not scanning
|
||||
- *(frontend)* Adds the new services to the app startup configuration
|
||||
- *(frontend)* Added correct headers and response format to LoginService
|
||||
- *(frontend)* Adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object.
|
||||
- *(API)* Added SaveChanges to Settings repository, reflecting changes to he database
|
||||
- *(API)* Solved filesystem scanner not scanning
|
||||
- Address code review findings (blocking .Result, async void handlers, email logging)
|
||||
- Handle stale ProtectedLocalStorage value crashing NavMenu deserialization
|
||||
- Serve appsettings.json dynamically from MilkStream to fix WASM BaseUrl in Docker
|
||||
- Support wildcard CORS origin for self-hosted Docker deployments
|
||||
- Replace CORS wildcard with explicit safe origins per environment
|
||||
- Add DB healthcheck and service_healthy condition to prevent lactose startup race condition
|
||||
- Catch OperationCanceledException from Task.Delay to prevent incorrect Completed status on cancellation
|
||||
- Notify auth info changed after token refresh and handle polling errors
|
||||
- Change ParentJobId to get/set for WASM compat, remove @key from JobTree, add debug panel
|
||||
- Handle 401 responses in JwtTokenRefresher by refreshing token and retrying
|
||||
- Redirect to login when token refresh fails in JwtTokenRefresher
|
||||
- Handle 401 in JobsService directly with refresh+retry
|
||||
- Add missing Butter.Types import in AssetRepository for EAssetType
|
||||
- Remove completed sub-jobs from list so master job can finish
|
||||
- Prevent master PHashJob from spinning indefinitely on sub-job completion
|
||||
- Use oninput for live slider value update and widen range layout
|
||||
- Add minutes unit to Folder Scan Interval description in default settings
|
||||
- Handle 401 transparently at AuthServiceBase level for all services
|
||||
- Redirect unauthenticated users from settings page to /login
|
||||
- Redirect unauthenticated users from jobs page to /login
|
||||
- *(ui)* Render child jobs in JobTree by skipping root filter at deeper levels
|
||||
- *(db)* Always override ThumbnailPath/PreviewPath values with local paths on init
|
||||
- *(docker)* Create thumbnails/previews dirs as app user so subdirs can be created at runtime
|
||||
- *(ui)* Restore auth in App.razor before rendering to prevent race redirect to /login
|
||||
- *(phash)* Display child jobs grouped by status
|
||||
- *(jobs)* Prevent silent crash on large thumbnail jobs and auto-refresh child jobs
|
||||
- *(ui)* Apply poll interval to child jobs, add @key for DOM reuse, guard admin pages
|
||||
- *(ui)* Submit login form on Enter key press
|
||||
- *(jobs)* Fix IntegrityCheckJob conditional + use soft-delete
|
||||
- *(jobs)* Add delay to JobManager main loop to prevent CPU spin
|
||||
- Add DeletedAt filter to asset batch queries to exclude soft-deleted assets
|
||||
- Add AssetUser many-to-many join table for SharedWith
|
||||
- Remove AspNetCore.SassCompiler to fix Docker startup crash
|
||||
- Proper bidirectional SharedWith many-to-many migration
|
||||
- *(Lactose)* Admin and Curators media visibility
|
||||
- Wire MaxConcurrentJobs to database setting on startup and runtime changes
|
||||
- Propagate cancellation to sub-jobs in batch processing jobs
|
||||
- Cancel past refresh loop when poll rate is set to Off
|
||||
- Make job cancellation work for all job states and types
|
||||
- Per-batch progress messages and counting bug in album/person jobs
|
||||
- Skip existing AlbumAsset relationships in CreateAlbumsJob to prevent duplicate key violations on re-run
|
||||
- Add JobBatchSize to DefaultSettings.json to prevent reset on restart
|
||||
- Populate LastLogin/BannedAt/UpdatedAt in UserInfoDto mapping
|
||||
- Rewrite TopTags query to avoid EF Core collection navigation translation error
|
||||
- Remove destructive People soft-delete in IntegrityCheckJob and drop duplicate hash stats
|
||||
- Move preview info text below image instead of overlaying on bottom border
|
||||
- Download button now fetches blob and triggers actual file download
|
||||
- Remove scroll position correction from LoadPreviousPage for smoother scroll-up
|
||||
- Propagate cancellation tokens through linked child job CTS
|
||||
- Album controller Save calls, person list endpoint, media empty path guard
|
||||
- Scoped CSS deep selectors for preview info text and filename binding
|
||||
- Switch album detail page to row-based flexbox masonry
|
||||
- Remove preventDefault from album links so they actually navigate
|
||||
- Remove content-visibility and contain-intrinsic-size from album detail tiles
|
||||
- Switch album detail grid from flexbox to CSS Grid
|
||||
- Use flexbox with align-items:flex-start for album detail grid
|
||||
- Unlock body scroll on navigation, reorder masonry for left-to-right flow
|
||||
- JS-based masonry for left-to-right item flow with varying heights
|
||||
- Faulty migration that could cause lactose crash on migration apply
|
||||
- Use CSS columns for albums page, keep JS masonry only for album detail
|
||||
- Override masonry inline styles with !important for grid mode
|
||||
- Force square aspect-ratio in grid mode over inline style
|
||||
- *(controllers)* Tighten asset visibility and media access checks
|
||||
- *(AssetController)* Correct search visibility filter for #11
|
||||
- *(AlbumController)* Exclude deleted assets from search visibility for #11
|
||||
- *(admin)* Add confirm/cancel for role change and prevent admin self-demotion
|
||||
- *(user)* Hash password in Create endpoint instead of storing raw
|
||||
- *(ui)* Remove broken onfocusout handler from role edit dropdown
|
||||
- *(admin)* Show inline errors instead of replacing entire page
|
||||
- *(auth)* Show ban/disabled message on login and force-logout on refresh
|
||||
- *(auth)* Detect banned/deleted user on page load via InitializeAsync
|
||||
- *(frontend)* Sorts setting by alphabetical
|
||||
- *(Lactose)* Change to use UtcNow, even if alredy working without timeline
|
||||
- *(backend)* Add [FromQuery] to person list endpoint to fix 415 error
|
||||
- *(css)* Move card styles to AlbumCard.razor.css, use ::deep for grid-to-card selectors
|
||||
- *(ui)* Larger cosplayer cards with profile pics, move edit buttons top-right, add masonry/grid toggle
|
||||
- *(auth)* Force logout on any refresh failure, redirect to login
|
||||
- *(ui)* Add scoped modal CSS to PersonForm for proper overlay rendering
|
||||
- *(ui)* Cosplayer cards now match album card style — image fills square, text overlay at bottom
|
||||
- *(js)* Set onload before src to handle cached images
|
||||
- *(ui)* Remove empty filler cells from banner mosaic, let overlay cover gaps naturally
|
||||
- *(ui)* Always render ProfileCropper in tree, use OnParametersSet to trigger JS init
|
||||
- *(ui)* Random 3 album covers max in banner, redesigned cropper with drag-to-move and drag-handle resize, image stays static
|
||||
- *(ui)* 4 corner handles on square boundary for resizing instead of single circular handle
|
||||
- *(js)* Handle positions relative to square, not viewport
|
||||
- *(js)* Corner-drag resize with fixed opposite corner, proper visual rate matching
|
||||
- *(css)* Show full image in cropper viewport (contain instead of cover)
|
||||
- *(ui)* Correct geometry — square outer, circle inscribed inside; image-relative crop coords with background-image viewport; correct display formula for background-position
|
||||
- *(js)* Adjust stored cropZoom by image aspect ratio factor so background-size formula matches circle
|
||||
- *(js)* Store cropZoom as image-relative fraction (square_side / image_displayed_width), no adjustment needed on save
|
||||
- *(ui)* Use pixel-exact transform positioning for avatar display instead of background-size formula
|
||||
- *(js)* Use correct opposite corners for TL/TR/BL resize handles
|
||||
- *(js)* Skip resize on first mousemove if mouse hasn't moved from mousedown position
|
||||
- *(js)* Resize calculations now in viewport pixels to avoid image-relative X/Y scale mismatch on non-square images
|
||||
- *(api)* Allow unlinking person from album by directly assigning null PersonOwnerId
|
||||
- *(build)* Remove stray semicolon in onclick lambda
|
||||
- Prevent album title from being overwritten when only setting Person in update DTO
|
||||
- *(pr)* Implement BulkUpdate, BulkDelete, revert docker-compose, explain [FromQuery]
|
||||
- *(tests)* Fix pre-existing auth route tests and handle empty unassigned albums gracefully
|
||||
- *(test)* Use response.body directly instead of JSON.parse (already parsed object)
|
||||
- *(test)* Update name assertion to match renamed person
|
||||
- Create AlbumSearchParametersDto, add album pagination to person detail, fix duplicate XML param
|
||||
- Cache random banner covers in field, not computed on every render
|
||||
- *(ui)* Responsive layout fixes for mobile portrait mode (closes #48)
|
||||
- *(ui)* Collapse AdminUsers table columns and actions on mobile (#48)
|
||||
- *(frontend)* Removed faulty d-flex tag on main object
|
||||
- *(milkystream)* Fix sorted masonry for albums
|
||||
- *(ui)* Prevent JobRow horizontal overflow on mobile portrait
|
||||
- *(ui)* Change AdminUsers mobile actions dropdown from dropup to dropdown
|
||||
- *(ui)* Reduce password input width on AdminUsers to prevent cell overflow
|
||||
- *(ui)* Stack setting card description and control vertically on mobile
|
||||
- *(ui)* Prevent layout shift from save indicator width change
|
||||
- *(ui)* Prevent save indicator from shifting adjacent controls
|
||||
- *(ui)* Stack setting card body vertically — description above, control + indicator below
|
||||
- *(ui)* Right-align number inputs, let them grow to fill card width
|
||||
- *(ui)* Replace inline save indicator with Bootstrap toast notification
|
||||
- *(ui)* Restore progress bar visibility on job rows
|
||||
- Resolve UI issues #61, #62, #66
|
||||
- *(#62)* Move mobile modal fix to global MilkyShot.css — scoped ::deep cannot pierce PersonForm root element
|
||||
- *(#62)* Use explicit top/left/right/bottom instead of inset, add !important to ensure position:fixed takes effect on mobile
|
||||
- *(#62)* Lock body scroll when modal forms open to prevent position:fixed breakage on mobile
|
||||
- Propagate server-side error messages from UpdateUserAsync to UI
|
||||
- Auto-close modal on password change success, add page-level success message
|
||||
- Center profile card on page
|
||||
- Wire profile icon in navbar to user profile page
|
||||
- Default sort users table by role descending (admin on top)
|
||||
- Use bold (700) instead of semi-bold for reliable font rendering
|
||||
- *(mobile)* Keep back button inline with title, collapse admin actions into dropdown
|
||||
- Batch-processing subjobs report correct status on asset failures
|
||||
- Remove double-count of failed assets in master Done handler
|
||||
- Metadata icon in dropdown uses text-light for visibility
|
||||
- Metadata icon visible in job list rows
|
||||
- Pass null since on first child fetch instead of DateTime.MinValue
|
||||
- Eagerly fetch children on load and hide sub-jobs placeholder
|
||||
- Re-render after eager child fetch so icons appear immediately
|
||||
- Eager-fetch children for all roots including past jobs
|
||||
- Update home logged-out message to match cosplayers wording
|
||||
- Update albums logged-out message to match home/cosplayers
|
||||
- Translate GetAllVisible to client-side eval for SharedWith navigation
|
||||
- Suppress nullable warnings on Include chain in GetAllVisible
|
||||
- Remove soft-delete from Person model
|
||||
- *(jobs)* Add hover/active variants for all job types via darken()
|
||||
- *(ui)* Modal icon order, fill all outline buttons
|
||||
- *(favicon)* Render MilkyShot logo as PNG favicon
|
||||
- *(ui)* Page bg radial gradient, navbar radial glow, favicon cache
|
||||
- *(cropper)* Revert ProfileCropper to inline modal
|
||||
- *(cropper)* Restore missing cropper CSS styles
|
||||
- *(cropper)* Add pointer-events:auto on handles, fix cursors
|
||||
- *(ui)* Fix float values in the cosplayer thumbnail and cosplayer card
|
||||
- *(ui)* Fix remaining float culture issues in CSS style attributes
|
||||
- *(ui)* Prevent infinite loading loop on cosplayers page with no data
|
||||
- *(ui)* Keep header and toolbar visible when albums search returns empty
|
||||
- *(ui)* Debounce search input and prevent backspace navigation
|
||||
- *(ui)* Refocus search input after debounce triggers reload
|
||||
- *(ui)* Move album grid CSS to AlbumGrid.razor.css for scoped isolation
|
||||
- *(ui)* Add height:100% to album-card-fallback so icon centers vertically in grid mode
|
||||
- *(ui)* Add margin-bottom to SortFilterBar for spacing from grid
|
||||
- *(ui)* Add ActionButtons slot to SortFilterBar, move album actions into bar
|
||||
- *(ui)* Push ActionButtons to right with ms-auto wrapper
|
||||
- *(ui)* Keep header and toolbar visible when cosplayers search returns empty
|
||||
- *(ui)* Move cosplayer grid CSS to CosplayerGrid.razor.css for scoped isolation
|
||||
- *(ui)* Move cosplayer actions into SortFilterBar, fix sortAsc always sending
|
||||
- Close outer div and remove invalid @{} blocks in SearchDropdown
|
||||
- Pressing enter with nothing selected does nothing
|
||||
- Move query param parsing to OnParametersSet
|
||||
- Parse search query before await in Albums OnInitializedAsync
|
||||
- Use case-insensitive ILike search for persons and albums
|
||||
- Override Bootstrap dropdown-menu display:none
|
||||
- Return 401 Unauthorized when user data is null in PersonController
|
||||
- Allow anonymous search for persons and albums
|
||||
- Materialize person search query before client-side visibility filter
|
||||
- Restrict user list to admin only
|
||||
- Add [FromQuery] to TagController.GetAll parameter
|
||||
- Use zero-based page numbers in AssetController to match API convention
|
||||
- Guard ILIKE filter in AlbumRepository.SearchQuery against null/empty query
|
||||
- Restore Include(p => p.Albums) in PersonRepository queries
|
||||
- Paginate PersonRepository.SearchQuery by ID before loading albums
|
||||
- Prevent duplicate @key errors across all list renderings
|
||||
- Filter album count by user visibility in person search
|
||||
- Restore ToAlbumPreviewDto mapper method per review feedback #91
|
||||
- Standardize pagination to zero-based across all layers
|
||||
- Move SharedWith visibility check to client side for translatable queries
|
||||
- Only reject Page < 0, keep original PageSize validation
|
||||
- Restore PageSize < 1 validation with upper limit of 250
|
||||
- *(cosplayer-detail)* Prevent banner image shuffle on album search/sort
|
||||
- *(cosplayer-detail)* Debounce search, keep page intact on filter changes
|
||||
- *(cosplayer-detail)* Remove search debounce, instant inline reload
|
||||
- Replace untranslatable Album navigation in stats queries with direct join table queries
|
||||
- Replace untranslatable Tags navigation in TopTags stats query with direct join table query
|
||||
- *(albums)* Use MaxPageSize constant for unassigned album fetch
|
||||
- Implement CosplayersMissingProfile stat
|
||||
- Make card components middle-clickable
|
||||
- Remove underline from album card person link
|
||||
- *(album-form)* Searchable person combobox with modal-safe positioning
|
||||
- *(jobs)* Use grey for queued segments in layered progress bar
|
||||
- *(repo)* Scope Maintainer to only maintained persons in GetAllVisible and SearchQuery
|
||||
- *(controller)* Scope Maintainer album visibility to only maintained persons
|
||||
- *(controller)* Scope Maintainer asset access to only maintained cosplayers in Get
|
||||
- *(controller)* Add Maintainer scope to Album BulkUpdate, Delete, BulkDelete
|
||||
- *(mapper)* Allow Maintainer to see Visibility field in DTO responses
|
||||
- *(media)* Replace bool parameter with EAccessLevel, add deleted asset check
|
||||
- *(stats)* Add ProtectedAssets count, fix PrivateAssets to count only Private
|
||||
- *(dto)* Default AssetCreateDto.Visibility to Private instead of Protected
|
||||
- *(repo)* Use PagedParametersDto.MaxPageSize const instead of hardcoded 150
|
||||
- *(migration)* Remap existing EAccessLevel values to prevent data corruption
|
||||
- *(migration)* Default Visibility to Protected instead of Public, preserve old IsPubliclyShared mapping
|
||||
- *(dto)* Make AssetCreateDto.UploadedBy nullable for scanner-created assets
|
||||
- *(model)* Add [PrimaryKey] annotation to PersonMaintainer for clarity
|
||||
- *(repo)* Scope AlbumRepository.SearchQuery maintainer visibility to maintained persons
|
||||
- *(controller)* Scope AssetController maintainer permissions via album→person chain
|
||||
- *(mapper)* Replace null-forgiving operator with null-conditional in AlbumMapper
|
||||
- *(controller)* Split Maintainer from User in AssetController.GetAll visibility filter
|
||||
- *(media)* Allow Maintainer to access media files for cosplayers they maintain
|
||||
- *(mapper)* Scope asset counts in album/ person previews to visible assets
|
||||
- Add missing app.css link to index.html
|
||||
- Null reference in User page maintainer display
|
||||
- Remove underline from user links, hide email from non-admin viewers
|
||||
- Remove underline from maintainer links, load assigned person names in user edit modal
|
||||
- NRE on user page when user data not yet loaded
|
||||
- Show email to self-viewing users on user page
|
||||
- Enforce data visibility per access level with test coverage
|
||||
- Replace jsonPath with response.body for null-value checks in visibility tests
|
||||
- Replace remaining jsonPath calls in asset visibility tests
|
||||
- Allow maintainer assignment for all user roles in UI
|
||||
- Address PR #127 review issues — fallback, eager load, indentation, Maintainer tests
|
||||
- Show maintained cosplayers section for all user types in admin edit modal
|
||||
- Show maintained cosplayers section for all user types in user profile edit modal
|
||||
- Add entity-level visibility gating for albums and persons
|
||||
- Update border colors and browse-mode visibility in AlbumDetail
|
||||
- Show borders for maintainers in select mode
|
||||
- Prevent jagged wrapping of toolbar and action buttons
|
||||
- Album-card-select and cosplayer-card-select only on selected cards
|
||||
- Wrap CosplayerDetail action buttons into mobile dropdown
|
||||
- Add cascade checkbox to CosplayerDetail album visibility modal
|
||||
- Cosplayers page bugs
|
||||
- Prevent private assets with null UploadedBy leaking to anonymous users
|
||||
- Homepage preview navigation, grid observer crash, and cascade labels
|
||||
- Label Delete button and cropper modal overlay
|
||||
- Album detail back button uses history.back() with fallback to /albums
|
||||
- Soft-delete users in UserController.Delete instead of hard-delete
|
||||
- Add ConfirmDialog to AdminUsers delete user flow
|
||||
- Add ConfirmDialog to User delete user flow
|
||||
- Add ConfirmDialog to AlbumDetail delete flows
|
||||
- Add ConfirmDialog to CosplayerDetail delete flows
|
||||
- Add ConfirmDialog to Albums bulk delete flow
|
||||
- Add ConfirmDialog to Cosplayers bulk delete flow
|
||||
- Add ConfirmDialog to Settings folder deletion flow
|
||||
- *(UI)* Make the delete cosplayer button in cosplayer detail page be just an icon to conform with albums detail page
|
||||
- Apply Visibility and MaintainerUserIds in PersonController.BulkUpdate
|
||||
- Split multi-request test blocks into single-request blocks
|
||||
- Guard against null PersonOwnerId in MergePeople albums query
|
||||
- Gate registration behind UserRegistrationEnabled setting
|
||||
- Handle 204/empty responses in AssetService and always reset isLoading in AlbumAssetPicker
|
||||
- Use Albums.Count==0 instead of !Any() for unlinked filter, add trace logging
|
||||
- Use OnParametersSetAsync instead of fire-and-forget to ensure Blazor re-renders after init
|
||||
- Use parameterized SqlQueryRaw instead of unquoted string.Format to prevent SQL error
|
||||
- Filter directory names to only paths containing a slash after prefix, excluding filenames
|
||||
- Restrict maintenance page and nav link to admin only
|
||||
- Add scoped CSS for maintenance page to make embedded picker fill viewport
|
||||
- Inline all markup in .razor template to restore CSS isolation
|
||||
- Change LoginService from Singleton to Scoped to match Blazored.LocalStorage lifetime
|
||||
- Simplify maintenance page CSS to flex-fill container without vh calc
|
||||
- Adjust embedded picker viewport height offset
|
||||
- Address PR #142 review issues
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- *(API)* Switch to repository pattern
|
||||
- *(API)* Switch to use UserRepository AuthController.cs
|
||||
- *(API)* Switch to repository pattern
|
||||
- *(API)* Switch to use UserRepository AuthController.cs
|
||||
- *(API)* Cleans the code
|
||||
- *(API)* Cleans the code
|
||||
- *(API)* Extract BulkDtoinseparate file
|
||||
- *(API)* Switch to use a class for paged search
|
||||
- *(API)* Adds Bulk capabilities
|
||||
- *(API)* Move classes and Standardlize
|
||||
- *(API)* Rename Asset Dtos
|
||||
- *(API)* AlbumController explicitly marked all of the x in lambdas as they were accidentally hiding previous stage, also improved null checking
|
||||
- Reformatted AssetController and AssetCreateDto files
|
||||
- *(API)* Asset controller reformatted params
|
||||
- Improve readability of CorsAllowedOrigins parsing block
|
||||
- *(jobs)* Flatten ThumbnailJob to batch-based sub-jobs
|
||||
- *(jobs)* Flatten PHashJob to batch-based sub-jobs
|
||||
- *(jobs)* Flatten MetadataJob to batch-based sub-jobs
|
||||
- Make LoginService singleton, centralize auth state and persistence
|
||||
- Extract ImagePreview shared component from Home and AlbumDetail
|
||||
- Remove list view, add grid/masonry toggle to album detail
|
||||
- *(css)* Move shared modal styles to global MilkyShot.css/SCSS, remove duplicated scoped CSS
|
||||
- *(css)* Remove SCSS, switch to plain CSS with Bootstrap custom properties
|
||||
- *(api)* Fold unassigned album filter into search endpoint via query param
|
||||
- *(dto)* Move Unassigned filter into PagedSearchParametersDto
|
||||
- Replace individual albumPage/albumSize params with PagedParametersDto in person detail endpoint
|
||||
- Replace custom masonry JS with Bootstrap grid + Masonry.js approach
|
||||
- *(auth)* Extract AuthServiceBase, replace retry with re-auth on 401
|
||||
- *(auth)* Remove SendWithRefreshAsync, rely solely on JwtTokenRefresher
|
||||
- *(ui)* Replace inline password form with modal
|
||||
- *(ui)* Complete user profile page redesign with card layout
|
||||
- Move cosplayer link to same line as album name
|
||||
- Center-align header, album name on top, cosplayer smaller below
|
||||
- Extract password strength UI into shared PasswordField component
|
||||
- Replace tuple + custom header with ChildrenResponse DTO
|
||||
- *(ui)* ModalFrame + EmptyState components, remove duplicate CSS, remove main border
|
||||
- *(jobs)* Use darken() for phash and create-albums hover/active too
|
||||
- Project PersonPreviewDto directly in repository instead of [NotMapped] entity property
|
||||
- Project AlbumPreviewDto directly in repository with visibility-aware asset count
|
||||
- Clean up imports, simplify checks, and add SearchDropdown component
|
||||
- *(albums)* Extract visibility-aware AssetCount into AlbumMapper overload
|
||||
- Replace hardcoded 250 page size limit with PagedParametersDto.MaxPageSize
|
||||
- Use MaxPageSize constant as default pageSize across all frontend services
|
||||
- Reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate
|
||||
- Move all visibility filtering from mappers and controllers to repositories
|
||||
- Rename AlbumSearchParametersDto.UploadedBy to AssetUploadedBy
|
||||
- Extract role badge colors to SCSS theme layer
|
||||
- Generate access level dropdown from enum values
|
||||
- Extract role checks to LoginService, update all consumers
|
||||
- Move CanEdit logic to LoginService, remove local page properties
|
||||
- Extract person search/assign UI into shared SearchAssignBadges component
|
||||
- Extract VisibilityModal shared component, add editing controls to Albums page
|
||||
- Centralize cascade visibility logic in AlbumService.CascadeVisibilityToAssetsAsync
|
||||
- Replace inline visibility modal with shared VisibilityModal in Cosplayers.razor
|
||||
- Move Unlink cosplayer button from album header to album edit form
|
||||
- Add Embedded mode, UnlinkedOnly param, rename methods from Unlinked to generic names
|
||||
- Rename UnlinkedAssetGroupDto to AssetGroupDto
|
||||
- Replace individual [FromQuery] params with AssetBrowseOptionsDto
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Update agents.md for Blazor WASM architecture
|
||||
- Add CORS configuration section to README
|
||||
- *(readme)* Add wildcard CORS warning and MilkStream CORS setup guide
|
||||
- Add XML documentation to all public APIs across all projects
|
||||
- Update AGENTS.md with XML doc enforcement and CORS guidance
|
||||
- Update AGENTS.md with verified infrastructure gotchas
|
||||
- Add API reuse convention to AGENTS.md — prefer optional query params over new endpoints
|
||||
- Add Gitea repo owner note to AGENTS.md
|
||||
- Add granular commit convention to AGENTS.md
|
||||
- Document REST Client tests in AGENTS.md
|
||||
- Update AGENTS.md with missing conventions and project info
|
||||
- Add use case catalog for new auth/permissions model
|
||||
- Add Gitea label levels note to AGENTS.md
|
||||
- Add PR workflow section to AGENTS.md
|
||||
- Reconcile AGENTS.md with current codebase
|
||||
- *(repo)* Fix stale pageSize default in IAlbumRepository XML doc
|
||||
- Add data size estimates to AGENTS.md
|
||||
- Clarify Curators and Admins can be maintainers for credited attribution
|
||||
- Fix stale column references and tooling versions in AGENTS.md
|
||||
- Add visibility rules (R1–R7) to AGENTS.md
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Push visibility filter into SQL for PersonRepository queries
|
||||
- Add partial index on Assets for visibility-aware search queries
|
||||
- Simplify ORDER BY to total count and add trigram GIN indexes
|
||||
- Add AsSplitQuery to repository queries with multiple Include calls
|
||||
- Replace in-memory path parsing with SQL SPLIT_PART aggregation and double-LIKE filter
|
||||
- Replace in-memory path parsing with SELECT DISTINCT SQL, remove dir count badges
|
||||
|
||||
### 🎨 Styling
|
||||
|
||||
- Style cosplayer link with inherited color and person icon
|
||||
- Match cosplayer link font size to h3, remove icon, keep regular weight
|
||||
- Consistent inline typography for album title and cosplayer name with dash separator
|
||||
- Borderless edit pencil with hover reveal on header
|
||||
- Cosplayer link dims on hover instead of blue/underline
|
||||
- Replace badge backgrounds with colored icons + matching launch buttons
|
||||
- Custom job color palette based on purpose, avoid red
|
||||
- Mobile launch button cyan for better contrast
|
||||
- Soften accent gradient stops (5%/10% instead of 15%/30%)
|
||||
- Use rounded-square thumbnails for album results
|
||||
- Add chevron-right indicator to search dropdown headers
|
||||
- Use Bootstrap dropdown-menu and dropdown-item classes
|
||||
- *(repo)* Convert visibility filters to switch expressions for readability
|
||||
- *(repo)* Expand switch expression arms for readability
|
||||
- *(repo)* Use explicit named enum cases in PersonRepository switch expressions
|
||||
- Standardize access level comparison to switch expressions
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
- More unit testing on HTTP client
|
||||
- *(api)* Add person endpoint tests to WepApiTest.http
|
||||
- *(api)* Add comprehensive http tests for all person and album endpoints
|
||||
- Add album update test without removePerson field
|
||||
- Use real album from search for update test, not zero-GUID fallback
|
||||
- All tests create their own data — create album returns ID, all mutations use self-created resources
|
||||
- Add endpoint tests for refresh token, albums, curator auth levels
|
||||
- Add anonymous search tests for persons and albums
|
||||
- Handle empty person search results gracefully
|
||||
- Set personId from admin search to ensure it's always populated
|
||||
- Add user id to profile update request body
|
||||
- Fix curator user access level from Admin (2) to Curator (1)
|
||||
- Restructure WepApiTest.http with three clearly separated users
|
||||
- Add cleanup of regular test user in REST Client suite
|
||||
- Add page=0&pageSize=5 to paginated list endpoints
|
||||
- Add merge and bulk visibility tests to WepApiTest.http
|
||||
- Flatten test numbering to sequential 1–137
|
||||
- Added rate limiting stress test
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Removes auto generated docker-compose file for deployment
|
||||
- *(docker)* Removes auto generated docker-compose file for deployment
|
||||
- *(API)* Changes the category name of the logger
|
||||
- Added datagrip files
|
||||
- *(config)* Separates connection string defaults for docker and local webserver run
|
||||
- Removed unused using in Program.cs
|
||||
- Swapped empty array initializer with empty collection initializer in Asset hash
|
||||
- Commented out unused logger in Album controller and added a TODO: about adding logging capabilities
|
||||
- Marked navigation property in folder as nullable
|
||||
- Commented out logger in FolderController and added a TODO regarding logging
|
||||
- Removed redundant type cast in tag search
|
||||
- Removed useless using declarations in TagMapper
|
||||
- Commented out the logger in tagController and added a TODO regarding logging
|
||||
- Removed more unused using directives
|
||||
- *(docker)* Added NoCache build option for lactose
|
||||
- *(docker)* Updated dockerfile to build using dotnet 9.0
|
||||
- *(NuGet)* [**breaking**] Added dependency to Magick.NET-Q16-HDRI-OpenMP-x64
|
||||
- Updated nuget packages to latest releases supporting Net 8.0, removed ImageMagick package.
|
||||
- Removed MySql package from required nuget packages
|
||||
- Refined db connector params
|
||||
- *(API)* Removes Redundant Check
|
||||
- Split TokenGeneratorProvider file in their respective classes
|
||||
- Moved milkstream services under a services namespace
|
||||
- *(frontend)* Switched to a more recent session storage implementation which doesn't have a security vulnerability.
|
||||
- Updated run profiles
|
||||
- Moved milkstream services under a services namespace
|
||||
- *(frontend)* Switched to a more recent session storage implementation which doesn't have a security vulnerability.
|
||||
- Updated run profiles
|
||||
- Remove temporary debug panel from Jobs page
|
||||
- Remove redundant XML doc comments from ServiceBase
|
||||
- Upgrade target framework to net10.0
|
||||
- Add dotnet-tools.json for ef tool
|
||||
- *(dev)* Fix ovveride PM Compose deploy
|
||||
- Remove debug auth logging middleware from Program.cs
|
||||
- Remove unused banner-cell-empty CSS class
|
||||
- *(test)* Remove Swagger test — disabled in container environments
|
||||
- Added the test images folder to the dockerignore avoiding them being sent every time a docker build starts
|
||||
- Revert docker-compose.yml to match develop
|
||||
- Updated packages, removed unused packages. ImageSharp now requires a license
|
||||
- Commit compiled CSS for role badge classes
|
||||
- Fix Dockerfiles, add debug compose to solution
|
||||
- Use record for config classes
|
||||
- Adjusted rate-limits of thumbs, preview and jobs to avoid triggering them during normal usage
|
||||
- Add Gitea Actions workflow to build and push containers on version tags
|
||||
|
||||
### 🛡️ Security
|
||||
|
||||
- Upgrade to .NET 10 — packages, Docker images, and Swagger fix
|
||||
|
||||
### ◀️ Revert
|
||||
|
||||
- Switch album detail back to masonry layout
|
||||
- Restore docker-compose.yml — testing-only CORS/baseUrl changes committed by accident
|
||||
- Restore docker-compose.yml LactoseBaseUrl to 192.168.50.100
|
||||
- Remove AllowAnonymous from AlbumController.Search and PersonController.GetAll
|
||||
- Change LoginService back to Singleton (DI error was from docker fast mode)
|
||||
|
||||
### CI
|
||||
|
||||
- Added build + push to internal registry action
|
||||
|
||||
### Fix
|
||||
|
||||
- *(API)* Disables Asset Create
|
||||
- *(API)* Refresh token being changed the last 10 minutes instead of every moment
|
||||
- *(API)* Refresh token being changed the last 10 minutes instead of every moment
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ public class LactoseDbContext : DbContext {
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
modelBuilder.HasPostgresExtension("pg_trgm");
|
||||
modelBuilder.HasDbFunction(() => PgFunctions.Md5(default!));
|
||||
//Album Relationships
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
|
||||
@@ -78,10 +79,32 @@ public class LactoseDbContext : DbContext {
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.CreatedAt })
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(a => a.OriginalFilename)
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm")
|
||||
.HasMethod("gin")
|
||||
.HasOperators("gin_trgm_ops");
|
||||
// Stats-supporting partial indexes
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.CreatedAt)
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.Type)
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.ThumbnailPath)
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.PreviewPath)
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.Hash)
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.ResolutionWidth, e.ResolutionHeight, e.Type })
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
// People
|
||||
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
|
||||
.HasDatabaseName("IX_People_Name_Trgm")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Maps PostgreSQL functions not covered by the Npgsql provider for use in LINQ-to-SQL queries.
|
||||
/// </summary>
|
||||
public static class PgFunctions {
|
||||
/// <summary>
|
||||
/// Maps to the PostgreSQL <c>md5</c> function. Only callable inside LINQ-to-SQL queries.
|
||||
/// </summary>
|
||||
/// <param name="value">The string to hash.</param>
|
||||
/// <returns>The md5 hex digest of <paramref name="value"/>.</returns>
|
||||
[DbFunction("md5", "pg_catalog")]
|
||||
public static string Md5(string value) => throw new NotSupportedException("PgFunctions.Md5 is only usable inside LINQ-to-SQL queries.");
|
||||
}
|
||||
@@ -67,7 +67,8 @@ public class AlbumController(
|
||||
pagingOptions.Unassigned,
|
||||
uid ?? default,
|
||||
accessLevel,
|
||||
pagingOptions.AssetUploadedBy
|
||||
pagingOptions.AssetUploadedBy,
|
||||
pagingOptions.PersonOwnerId
|
||||
).ToList();
|
||||
|
||||
return Ok(albums);
|
||||
@@ -85,7 +86,7 @@ public class AlbumController(
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.Find(id);
|
||||
var album = albumRepository.FindWithAssets(id);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
@@ -99,7 +99,8 @@ public class AssetController(
|
||||
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
|
||||
uid, accessLevel,
|
||||
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search
|
||||
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search,
|
||||
searchOptionsDto.IncludeCount
|
||||
);
|
||||
|
||||
logger.LogTrace(
|
||||
@@ -114,11 +115,9 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
var dtoList = assets.ToAssetPreviewDto(accessLevel, uid).ToList();
|
||||
|
||||
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
|
||||
logger.LogTrace($"Returning {assets.Count()} assets (total: {total})");
|
||||
Response.Headers["X-Total-Count"] = total.ToString();
|
||||
return Ok(dtoList);
|
||||
return Ok(assets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -97,6 +97,16 @@ public class AuthController(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether user registration is currently enabled.
|
||||
/// </summary>
|
||||
/// <returns>200 if enabled, 403 if disabled.</returns>
|
||||
[HttpGet("register")]
|
||||
public ActionResult CheckRegistrationEnabled() {
|
||||
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
|
||||
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new user account.
|
||||
/// </summary>
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace Lactose.Controllers;
|
||||
Active = false
|
||||
};
|
||||
|
||||
folderRepository.Create(newFolder);
|
||||
folderRepository.Insert(newFolder);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ namespace Lactose.Controllers;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
folderRepository.Delete(id);
|
||||
folderRepository.Remove(id);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ public class MediaController(
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Thumbnails are immutable per asset — allow browser/proxy caching
|
||||
Response.Headers.CacheControl = "public, max-age=86400";
|
||||
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
|
||||
return PhysicalFile(asset.ThumbnailPath, "image/webp");
|
||||
@@ -88,6 +91,9 @@ public class MediaController(
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Previews are immutable per asset — allow browser/proxy caching
|
||||
Response.Headers.CacheControl = "public, max-age=86400";
|
||||
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
|
||||
return PhysicalFile(asset.PreviewPath, "image/webp");
|
||||
|
||||
@@ -20,11 +20,12 @@ public class StatsController(
|
||||
/// <summary>
|
||||
/// Returns comprehensive aggregate statistics about the instance.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A <see cref="StatsDto"/> with all gathered statistics.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<StatsDto> Get() {
|
||||
public async Task<ActionResult<StatsDto>> Get(CancellationToken cancellationToken) {
|
||||
logger.LogTrace("Stats requested");
|
||||
var stats = statsRepository.GetStats();
|
||||
var stats = await statsRepository.GetStatsAsync(cancellationToken);
|
||||
return Ok(stats);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
static class PathUtils {
|
||||
internal static string PathFromGuid(Guid id, string root) {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ public class PreviewJob : Job {
|
||||
)
|
||||
);
|
||||
|
||||
var path = PathFromGuid(asset.Id, PreviewPath!);
|
||||
var path = PathUtils.PathFromGuid(asset.Id, PreviewPath!);
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(path)))
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
@@ -279,16 +279,4 @@ public class PreviewJob : Job {
|
||||
else
|
||||
JobStatus.Complete("All previews generated successfully.");
|
||||
}
|
||||
|
||||
static string PathFromGuid(Guid id, string root) {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ public class ThumbnailJob : Job {
|
||||
)
|
||||
);
|
||||
|
||||
var path = PathFromGuid(asset.Id, ThumbnailPath!);
|
||||
var path = PathUtils.PathFromGuid(asset.Id, ThumbnailPath!);
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(path)))
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
@@ -279,16 +279,4 @@ public class ThumbnailJob : Job {
|
||||
else
|
||||
JobStatus.Complete("All thumbnails generated successfully.");
|
||||
}
|
||||
|
||||
static string PathFromGuid(Guid id, string root) {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ public static class AlbumMapper {
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
CoverWidth = album.CoverAsset?.ResolutionWidth,
|
||||
CoverHeight = album.CoverAsset?.ResolutionHeight,
|
||||
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null
|
||||
@@ -28,31 +30,38 @@ public static class AlbumMapper {
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumFullDto object.
|
||||
/// Uses pre-projected <see cref="Album.VisibleAssetPreviews"/> when available; otherwise falls back to mapping loaded assets.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>An AlbumFullDto object.</returns>
|
||||
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) => new AlbumFullDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null,
|
||||
Images = album.Assets?.Select(asset => asset.Id).ToList() ?? [],
|
||||
AssetPreviews = album.Assets?.Select(asset => new AlbumAssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||
}).ToList() ?? []
|
||||
};
|
||||
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) {
|
||||
var previews = album.VisibleAssetPreviews
|
||||
?? album.Assets?.Select(asset => new AlbumAssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||
}).ToList()
|
||||
?? [];
|
||||
|
||||
return new AlbumFullDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = previews.Count(p => p.DeletedAt == null),
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null,
|
||||
Images = previews.Select(preview => preview.Id).ToList(),
|
||||
AssetPreviews = previews
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -27,10 +27,14 @@ public static class PersonMapper {
|
||||
ProfileCropX = person.ProfileCropX,
|
||||
ProfileCropY = person.ProfileCropY,
|
||||
ProfileCropZoom = person.ProfileCropZoom,
|
||||
TotalAlbums = albums.Count,
|
||||
TotalAssets = albums.Sum(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0),
|
||||
TotalAlbums = person.AlbumTotalCount,
|
||||
TotalAssets = person.TotalAssetCount,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null,
|
||||
Albums = albums.Select(a => a.ToAlbumPreviewDto(accessLevel, viewerId)).ToList(),
|
||||
Albums = albums.Select(a => {
|
||||
var dto = a.ToAlbumPreviewDto(accessLevel, viewerId);
|
||||
dto.AssetCount = person.AlbumAssetCounts?.GetValueOrDefault(a.Id, 0) ?? 0;
|
||||
return dto;
|
||||
}).ToList(),
|
||||
MaintainerUserIds = maintainers.Select(u => u.Id).ToList(),
|
||||
MaintainerUsernames = maintainers.Select(u => u.Username).ToList()
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
+601
@@ -0,0 +1,601 @@
|
||||
// <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("20260811104813_AddAssetVisibleCreatedAtIndex")]
|
||||
partial class AddAssetVisibleCreatedAtIndex
|
||||
{
|
||||
/// <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("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<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
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.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<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
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.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.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.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
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.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
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("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
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.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
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("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAssetVisibleCreatedAtIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_VisibleCreatedAt",
|
||||
table: "Assets",
|
||||
columns: new[] { "Visibility", "CreatedAt" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_VisibleCreatedAt",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
// <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("20260817205017_OptimizeStatsIndexes")]
|
||||
partial class OptimizeStatsIndexes
|
||||
{
|
||||
/// <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("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<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
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.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<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
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.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.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.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
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.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
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("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
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.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
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("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OptimizeStatsIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_CreatedAt",
|
||||
table: "Assets",
|
||||
column: "CreatedAt",
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_MissingPhash",
|
||||
table: "Assets",
|
||||
column: "Hash",
|
||||
filter: "\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_MissingPreview",
|
||||
table: "Assets",
|
||||
column: "PreviewPath",
|
||||
filter: "\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_MissingThumbnail",
|
||||
table: "Assets",
|
||||
column: "ThumbnailPath",
|
||||
filter: "\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_Resolution",
|
||||
table: "Assets",
|
||||
columns: new[] { "ResolutionWidth", "ResolutionHeight", "Type" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_Type",
|
||||
table: "Assets",
|
||||
column: "Type",
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_CreatedAt",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_MissingPhash",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_MissingPreview",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_MissingThumbnail",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_Resolution",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_Type",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,8 +177,16 @@ namespace Lactose.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
@@ -188,12 +196,32 @@ namespace Lactose.Migrations
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
@@ -66,4 +67,11 @@ public class Album {
|
||||
public Asset? CoverAsset { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility-filtered asset previews for the album detail view.
|
||||
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public List<AlbumAssetPreviewDto>? VisibleAssetPreviews { get; set; }
|
||||
}
|
||||
|
||||
@@ -79,4 +79,25 @@ public class Person {
|
||||
public List<User>? Maintainers { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of albums (before pagination).
|
||||
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public int AlbumTotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of non-deleted assets across all visible albums.
|
||||
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public int TotalAssetCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-album visible asset counts for the paged album list.
|
||||
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public Dictionary<Guid, int>? AlbumAssetCounts { get; set; }
|
||||
}
|
||||
+15
-4
@@ -18,10 +18,6 @@ using Npgsql;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
//Adds configurations to the builder
|
||||
builder.Configuration.AddJsonFile("appsettings.json", true, true)
|
||||
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true);
|
||||
|
||||
builder.Services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
|
||||
|
||||
// CORS: allow the Blazor WASM frontend to call this API from the browser.
|
||||
@@ -171,6 +167,7 @@ builder.Services.AddSingleton<JobManager>();
|
||||
builder.Services.AddSingleton<JobScheduler>();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
builder.Services.AddSwaggerGen(
|
||||
options => {
|
||||
@@ -249,4 +246,18 @@ app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
//app.UseHttpsRedirection();
|
||||
app.MapControllers();
|
||||
|
||||
// Auto-save middleware: ensures pending changes are persisted at the end of every
|
||||
// non-GET request even if the caller forgets to call Save() explicitly.
|
||||
// This is a safety net — explicit Save() calls in controllers and jobs still work
|
||||
// as before and take effect immediately. The middleware's SaveChanges is a no-op
|
||||
// when no tracked changes remain.
|
||||
app.Use(async (context, next) => {
|
||||
await next();
|
||||
if (context.Request.Method != "GET") {
|
||||
var db = context.RequestServices.GetRequiredService<LactoseDbContext>();
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -27,8 +27,8 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null) {
|
||||
IQueryable<Album> albumsQuery = context.Albums;
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null, Guid? personOwnerId = null) {
|
||||
IQueryable<Album> albumsQuery = context.Albums.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
albumsQuery = albumsQuery.Where(x => EF.Functions.ILike(x.Title, $"%{query}%"));
|
||||
@@ -39,6 +39,9 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
if (uploadedBy.HasValue)
|
||||
albumsQuery = albumsQuery.Where(a => a.Assets!.Any(asset => asset.UploadedBy == uploadedBy.Value));
|
||||
|
||||
if (personOwnerId.HasValue)
|
||||
albumsQuery = albumsQuery.Where(a => a.PersonOwnerId == personOwnerId.Value);
|
||||
|
||||
// Apply visibility filter scoped to access level
|
||||
albumsQuery = accessLevel switch {
|
||||
< EAccessLevel.Maintainer => albumsQuery.Where(a => a.Visibility <= EVisibility.Protected),
|
||||
@@ -69,7 +72,9 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
return [];
|
||||
|
||||
var pagedAlbums = context.Albums
|
||||
.AsNoTracking()
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.CoverAsset)
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.ToList();
|
||||
|
||||
@@ -108,20 +113,19 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
public void Save() => context.SaveChanges();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? Find(Guid id) => context.Albums
|
||||
public Album? Find(Guid id) => context.Albums.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? FindWithAssets(Guid id) => context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.CoverAsset)
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
var album = context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.CoverAsset)
|
||||
.AsNoTracking()
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
if (album == null) return null;
|
||||
@@ -138,33 +142,40 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
|
||||
if (!canSeeAlbum) return null;
|
||||
|
||||
if (album.Assets == null) return album;
|
||||
// R2: Filter assets at the database level and project only the DTO fields
|
||||
IQueryable<Asset> assetsQuery = context.Assets
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Albums!.Any(al => al.Id == id));
|
||||
|
||||
album.Assets = accessLevel switch {
|
||||
EAccessLevel.Admin => album.Assets,
|
||||
EAccessLevel.Curator => album.Assets.Where(a => a.DeletedAt == null || a.UploadedBy == userId).ToList(),
|
||||
EAccessLevel.Maintainer when userId.HasValue => FilterAssetsForMaintainer(album, userId.Value),
|
||||
_ => album.Assets.Where(a => a.DeletedAt == null && (
|
||||
assetsQuery = accessLevel switch {
|
||||
EAccessLevel.Admin => assetsQuery,
|
||||
EAccessLevel.Curator => assetsQuery.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
|
||||
EAccessLevel.Maintainer when userId.HasValue && album.PersonOwnerId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value)
|
||||
=> assetsQuery.Where(a => a.DeletedAt == null),
|
||||
_ => assetsQuery.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(a.Visibility == EVisibility.Protected && (userId.HasValue || accessLevel >= EAccessLevel.Maintainer)) ||
|
||||
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
|
||||
)).ToList()
|
||||
))
|
||||
};
|
||||
|
||||
album.VisibleAssetPreviews = assetsQuery
|
||||
.OrderBy(a => a.OriginalFilename)
|
||||
.Select(a => new AlbumAssetPreviewDto {
|
||||
Id = a.Id,
|
||||
ResolutionWidth = a.ResolutionWidth,
|
||||
ResolutionHeight = a.ResolutionHeight,
|
||||
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
|
||||
HasPreview = a.PreviewPath != null && a.PreviewPath != "",
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? a.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return album;
|
||||
}
|
||||
|
||||
private List<Asset> FilterAssetsForMaintainer(Album album, Guid userId) {
|
||||
if (album.PersonOwnerId.HasValue && context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId && pm.PersonId == album.PersonOwnerId.Value))
|
||||
return album.Assets!.Where(a => a.DeletedAt == null).ToList();
|
||||
|
||||
return album.Assets!.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
a.Visibility == EVisibility.Protected ||
|
||||
a.UploadedBy == userId
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Album> FindByPerson(Guid personId) => context.Albums.Where(a => a.PersonOwnerId == personId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Mapper;
|
||||
@@ -177,8 +178,8 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
.Take(limit);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null) {
|
||||
var query = context.Assets.AsQueryable();
|
||||
public IEnumerable<AssetPreviewDto> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true) {
|
||||
var query = context.Assets.AsNoTracking().AsQueryable();
|
||||
if (type.HasValue)
|
||||
query = query.Where(a => a.Type == type.Value);
|
||||
if (from.HasValue)
|
||||
@@ -213,45 +214,61 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
))
|
||||
};
|
||||
|
||||
total = query.Count();
|
||||
total = includeCount ? query.Count() : 0;
|
||||
|
||||
if (orderRandomly && seed.HasValue) {
|
||||
var allIds = query.Select(a => a.Id).ToList();
|
||||
var seedHash = seed.Value.GetHashCode();
|
||||
var shuffledIds = allIds
|
||||
.OrderBy(id => DeterministicHash(id, seedHash))
|
||||
.Skip(pageNumber * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
// Deterministic page of IDs first: random order is a seeded md5 shuffle on the server,
|
||||
// so the full ID set is never materialized in memory.
|
||||
IQueryable<Asset> ordered = orderRandomly switch {
|
||||
true when seed.HasValue => query.OrderBy(a => PgFunctions.Md5(a.Id.ToString() + seed.Value.ToString())),
|
||||
true => query.OrderBy(a => a.Id),
|
||||
false => query.OrderByDescending(a => a.CreatedAt)
|
||||
};
|
||||
|
||||
var assets = context.Assets
|
||||
.AsSplitQuery()
|
||||
.Where(a => shuffledIds.Contains(a.Id))
|
||||
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner)
|
||||
.ToList();
|
||||
|
||||
return shuffledIds.Select(id => assets.First(a => a.Id == id)).ToList();
|
||||
}
|
||||
|
||||
IQueryable<Asset> dataQuery = query
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner);
|
||||
dataQuery = orderRandomly ? dataQuery.OrderBy(a => a.Id) : dataQuery.OrderByDescending(a => a.CreatedAt);
|
||||
|
||||
return dataQuery
|
||||
var pageIds = ordered
|
||||
.Skip(pageNumber * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static int DeterministicHash(Guid id, int seed) {
|
||||
var bytes = id.ToByteArray();
|
||||
unchecked {
|
||||
var hash = seed;
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
hash = hash * 31 + bytes[i];
|
||||
return hash & 0x7FFFFFFF;
|
||||
if (pageIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var assets = query
|
||||
.Where(a => pageIds.Contains(a.Id))
|
||||
.Select(a => new AssetPreviewDto {
|
||||
Id = a.Id,
|
||||
MimeType = a.MimeType,
|
||||
ResolutionWidth = a.ResolutionWidth,
|
||||
ResolutionHeight = a.ResolutionHeight,
|
||||
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
|
||||
HasPreview = a.PreviewPath != null && a.PreviewPath != "",
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? a.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Album and cosplayer names per asset via a single grouped query
|
||||
var albumLinks = (from a in context.Assets
|
||||
from al in a.Albums!
|
||||
where pageIds.Contains(a.Id)
|
||||
select new { AssetId = a.Id, al.Id, al.Title, al.PersonOwnerId, PersonName = al.PersonOwner!.Name })
|
||||
.ToList();
|
||||
|
||||
var linksByAsset = albumLinks
|
||||
.GroupBy(x => x.AssetId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var dto in assets) {
|
||||
if (!linksByAsset.TryGetValue(dto.Id, out var links)) continue;
|
||||
dto.AlbumNames = links.Select(x => x.Title).Distinct().ToList();
|
||||
dto.AlbumIds = links.Select(x => x.Id).Distinct().ToList();
|
||||
dto.CosplayerNames = links.Where(x => x.PersonOwnerId.HasValue).Select(x => x.PersonName).Distinct().ToList();
|
||||
dto.CosplayerIds = links.Where(x => x.PersonOwnerId.HasValue).Select(x => x.PersonOwnerId!.Value).Distinct().ToList();
|
||||
}
|
||||
|
||||
var orderMap = pageIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. assets.OrderBy(a => orderMap.GetValueOrDefault(a.Id))];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -17,7 +17,7 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
|
||||
static void OnFolderRemoved(Folder e) => FolderRemoved?.Invoke(null, e);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Create(Folder folder) {
|
||||
public void Insert(Folder folder) {
|
||||
context.Folders.Add(folder);
|
||||
context.SaveChanges();
|
||||
if (folder.Active) OnFolderAdded(folder);
|
||||
@@ -48,7 +48,7 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Delete(Guid id) {
|
||||
public void Remove(Guid id) {
|
||||
var folder = context.Folders.Find(id);
|
||||
|
||||
if (folder != null) {
|
||||
|
||||
@@ -39,6 +39,13 @@ public interface IAlbumRepository : IDisposable {
|
||||
/// <returns>The album if found; otherwise, null.</returns>
|
||||
public Album? Find(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an album by its ID with its assets loaded for collection replacement.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the album.</param>
|
||||
/// <returns>The album with its asset collection loaded if found; otherwise, null.</returns>
|
||||
public Album? FindWithAssets(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an album by its ID with assets filtered by the requesting user's access level.
|
||||
/// </summary>
|
||||
@@ -96,7 +103,8 @@ public interface IAlbumRepository : IDisposable {
|
||||
/// <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>
|
||||
/// <param name="uploadedBy">Optional filter for albums containing assets uploaded by a specific user.</param>
|
||||
/// <param name="personOwnerId">Optional filter for albums owned by a specific person.</param>
|
||||
/// <returns>A list of album previews matching the search query.</returns>
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null);
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null, Guid? personOwnerId = null);
|
||||
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
@@ -193,8 +194,9 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="folderId">Optional folder ID to filter assets by their scan folder.</param>
|
||||
/// <param name="uploadedBy">Optional uploader user ID to filter assets by their uploader.</param>
|
||||
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
|
||||
/// <returns>A paginated collection of assets matching the filters.</returns>
|
||||
IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null);
|
||||
/// <param name="includeCount">If true (default), computes the total matching count. When false, <paramref name="total"/> is set to zero and the count query is skipped.</param>
|
||||
/// <returns>A paginated collection of asset previews matching the filters.</returns>
|
||||
IEnumerable<AssetPreviewDto> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true);
|
||||
|
||||
/// <summary>
|
||||
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
|
||||
|
||||
@@ -10,7 +10,7 @@ public interface IFolderRepository : IDisposable {
|
||||
/// Creates a new folder.
|
||||
/// </summary>
|
||||
/// <param name="folder">The folder to create.</param>
|
||||
void Create(Folder folder);
|
||||
void Insert(Folder folder);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing folder.
|
||||
@@ -20,10 +20,10 @@ public interface IFolderRepository : IDisposable {
|
||||
void Update(Guid id, Folder folder);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a folder by ID.
|
||||
/// Removes a folder by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the folder to delete.</param>
|
||||
void Delete(Guid id);
|
||||
/// <param name="id">The ID of the folder to remove.</param>
|
||||
void Remove(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a folder by ID.
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Lactose.Repositories;
|
||||
/// <summary>
|
||||
/// Interface for media repository to handle media data retrieval.
|
||||
/// </summary>
|
||||
public interface IMediaRepository {
|
||||
public interface IMediaRepository : IDisposable {
|
||||
/// <summary>
|
||||
/// Retrieves the original media data.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,6 +3,8 @@ using Butter.Dtos.Person;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for person repository operations.
|
||||
/// </summary>
|
||||
|
||||
@@ -5,10 +5,11 @@ namespace Lactose.Repositories;
|
||||
/// <summary>
|
||||
/// Repository for gathering aggregate statistics from the database.
|
||||
/// </summary>
|
||||
public interface IStatsRepository : IDisposable {
|
||||
public interface IStatsRepository {
|
||||
/// <summary>
|
||||
/// Gathers all aggregate statistics into a single DTO.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A fully populated <see cref="StatsDto"/>.</returns>
|
||||
StatsDto GetStats();
|
||||
}
|
||||
Task<StatsDto> GetStatsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -49,10 +49,10 @@ public interface ITagRepository : IDisposable {
|
||||
void Save();
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a tag by the given <paramref name="id"/>
|
||||
/// Deletes the given tag from the repository.
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
void Delete(Tag id);
|
||||
/// <param name="tag"></param>
|
||||
void Delete(Tag tag);
|
||||
|
||||
/// <summary>
|
||||
/// Insert a new Tag
|
||||
|
||||
@@ -92,4 +92,10 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR
|
||||
Path = media.PreviewPath
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() {
|
||||
context.Dispose();
|
||||
personRepository.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -16,23 +16,14 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
public void Save() => context.SaveChanges();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? Find(Guid id) => context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.ProfileAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == id);
|
||||
public Person? Find(Guid id) => context.People.FirstOrDefault(p => p.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? FindVisible(Guid id, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User,
|
||||
string? albumSearch = null, string? albumSortBy = null, bool albumSortAsc = true,
|
||||
int albumPage = 0, int albumPageSize = PagedParametersDto.MaxPageSize) {
|
||||
var person = context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.ProfileAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == id);
|
||||
|
||||
@@ -49,77 +40,118 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
|
||||
if (!canSeePerson) return null;
|
||||
|
||||
if (person.Albums == null) return person;
|
||||
bool isMaintainerOfPerson = accessLevel == EAccessLevel.Maintainer && userId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id);
|
||||
|
||||
// Filter albums and assets by visibility
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Admin:
|
||||
case EAccessLevel.Curator:
|
||||
IQueryable<Album> albumsQuery = context.Albums
|
||||
.AsNoTracking()
|
||||
.Where(a => a.PersonOwnerId == id);
|
||||
|
||||
// Album search — ILike so the trgm GIN index on Title can be used
|
||||
if (!string.IsNullOrEmpty(albumSearch))
|
||||
albumsQuery = albumsQuery.Where(a => EF.Functions.ILike(a.Title, $"%{albumSearch}%"));
|
||||
|
||||
// Album visibility filter. Admin, curators, and maintainers of this person see all albums;
|
||||
// everyone else sees Protected-or-below albums plus albums that contain at least one visible asset.
|
||||
bool seesAllAlbums = accessLevel >= EAccessLevel.Curator || isMaintainerOfPerson;
|
||||
if (!seesAllAlbums) {
|
||||
albumsQuery = albumsQuery.Where(a => a.Visibility <= EVisibility.Protected || a.Assets!.Any(
|
||||
asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
)));
|
||||
}
|
||||
|
||||
IOrderedQueryable<Album> ordered;
|
||||
switch (albumSortBy?.ToLowerInvariant()) {
|
||||
case "name":
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title);
|
||||
break;
|
||||
|
||||
case EAccessLevel.Maintainer when userId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id):
|
||||
case "created":
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt);
|
||||
break;
|
||||
|
||||
default:
|
||||
person.Albums = person.Albums
|
||||
.Where(a => a.Visibility <= EVisibility.Protected || (
|
||||
a.Assets != null && a.Assets.Any(asset =>
|
||||
asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
)
|
||||
)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
foreach (var album in person.Albums) {
|
||||
if (album.Assets != null) {
|
||||
album.Assets = album.Assets
|
||||
.Where(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
case "updated":
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.UpdatedAt) : albumsQuery.OrderByDescending(a => a.UpdatedAt);
|
||||
break;
|
||||
case "assets":
|
||||
if (seesAllAlbums) {
|
||||
ordered = albumSortAsc
|
||||
? albumsQuery.OrderBy(a => a.Assets!.Count(asset => asset.DeletedAt == null))
|
||||
: albumsQuery.OrderByDescending(a => a.Assets!.Count(asset => asset.DeletedAt == null));
|
||||
} else {
|
||||
ordered = albumSortAsc
|
||||
? albumsQuery.OrderBy(a => a.Assets!.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId))))
|
||||
: albumsQuery.OrderByDescending(a => a.Assets!.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId))));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt);
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply album search
|
||||
if (!string.IsNullOrEmpty(albumSearch)) {
|
||||
person.Albums = person.Albums
|
||||
.Where(a => a.Title.Contains(albumSearch, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
// Total album count (after search and visibility filters, before pagination)
|
||||
person.AlbumTotalCount = ordered.Count();
|
||||
|
||||
// Total visible assets across all visible albums of the person (before search and pagination)
|
||||
if (seesAllAlbums) {
|
||||
person.TotalAssetCount = context.Assets
|
||||
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
|
||||
.Count(asset => asset.DeletedAt == null);
|
||||
} else {
|
||||
person.TotalAssetCount = context.Assets
|
||||
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
|
||||
.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)));
|
||||
}
|
||||
|
||||
// Apply album sorting
|
||||
if (!string.IsNullOrEmpty(albumSortBy)) {
|
||||
person.Albums = albumSortBy.ToLowerInvariant() switch {
|
||||
"name" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.Title)]
|
||||
: [..person.Albums.OrderByDescending(a => a.Title)],
|
||||
"created" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.CreatedAt)]
|
||||
: [..person.Albums.OrderByDescending(a => a.CreatedAt)],
|
||||
"updated" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.UpdatedAt)]
|
||||
: [..person.Albums.OrderByDescending(a => a.UpdatedAt)],
|
||||
"assets" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)]
|
||||
: [..person.Albums.OrderByDescending(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)],
|
||||
_ => person.Albums
|
||||
};
|
||||
}
|
||||
|
||||
// Apply album pagination
|
||||
person.Albums = person.Albums
|
||||
var albumIds = ordered
|
||||
.Skip(albumPage * albumPageSize)
|
||||
.Take(albumPageSize)
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
|
||||
if (albumIds.Count == 0)
|
||||
return person;
|
||||
|
||||
person.Albums = context.Albums
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.CoverAsset)
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.ToList();
|
||||
|
||||
// Per-album visible asset counts at the database level
|
||||
if (seesAllAlbums) {
|
||||
person.AlbumAssetCounts = context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, Count = a.Assets!.Count(asset => asset.DeletedAt == null) })
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
} else {
|
||||
person.AlbumAssetCounts = context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new {
|
||||
a.Id,
|
||||
Count = a.Assets!.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)))
|
||||
})
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
}
|
||||
|
||||
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
person.Albums = [.. person.Albums.OrderBy(a => orderMap.GetValueOrDefault(a.Id))];
|
||||
|
||||
return person;
|
||||
}
|
||||
|
||||
@@ -202,12 +234,21 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
return [];
|
||||
|
||||
var pagedPeople = context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.Albums)
|
||||
.AsNoTracking()
|
||||
.Where(p => personIds.Contains(p.Id))
|
||||
.ToList();
|
||||
|
||||
var dtos = pagedPeople.Select(p => p.ToPersonPreviewDto(accessLevel)).ToList();
|
||||
// Album counts via a single grouped query instead of loading every album row
|
||||
var albumCounts = context.People
|
||||
.Where(p => personIds.Contains(p.Id))
|
||||
.Select(p => new { p.Id, Count = p.Albums!.Count })
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
|
||||
var dtos = pagedPeople.Select(p => {
|
||||
var dto = p.ToPersonPreviewDto(accessLevel);
|
||||
dto.TotalAlbums = albumCounts.GetValueOrDefault(p.Id, 0);
|
||||
return dto;
|
||||
}).ToList();
|
||||
|
||||
var orderMap = personIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
|
||||
|
||||
@@ -3,150 +3,166 @@ using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Collections;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class StatsRepository(LactoseDbContext context, ISettingsRepository settingsRepo) : IStatsRepository {
|
||||
public class StatsRepository(
|
||||
LactoseDbContext context,
|
||||
ISettingsRepository settingsRepo,
|
||||
IMemoryCache cache
|
||||
) : IStatsRepository {
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <inheritdoc />
|
||||
public StatsDto GetStats() {
|
||||
var dto = new StatsDto();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
dto.TotalAssets = context.Assets.Count(a => a.DeletedAt == null);
|
||||
dto.TotalUsers = context.Users.Count(u => u.DeletedAt == null);
|
||||
dto.TotalAlbums = context.Albums.Count();
|
||||
dto.TotalTags = context.Tags.Count();
|
||||
dto.TotalPeople = context.People.Count();
|
||||
dto.TotalFaces = context.Faces.Count();
|
||||
dto.TotalFolders = context.Folders.Count();
|
||||
|
||||
dto.AssetsByType = context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.GroupBy(a => a.Type)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.Key, x => x.Count);
|
||||
|
||||
dto.TotalStorageBytes = context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.Sum(a => (long?)a.FileSize) ?? 0;
|
||||
|
||||
dto.StorageByType = context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.GroupBy(a => a.Type)
|
||||
.Select(g => new { g.Key, Size = g.Sum(a => (long?)a.FileSize) ?? 0 })
|
||||
.ToDictionary(x => x.Key, x => x.Size);
|
||||
|
||||
dto.UsersByAccessLevel = context.Users
|
||||
.Where(u => u.DeletedAt == null)
|
||||
.GroupBy(u => u.AccessLevel)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.Key, x => x.Count);
|
||||
|
||||
dto.AssetsAddedLast7Days = context.Assets
|
||||
.Count(a => a.DeletedAt == null && a.CreatedAt >= now.AddDays(-7));
|
||||
dto.AssetsAddedLast30Days = context.Assets
|
||||
.Count(a => a.DeletedAt == null && a.CreatedAt >= now.AddDays(-30));
|
||||
dto.UsersRegisteredLast30Days = context.Users
|
||||
.Count(u => u.DeletedAt == null && u.CreatedAt >= now.AddDays(-30));
|
||||
|
||||
dto.OrphanAssets = context.Assets.Count(a => a.DeletedAt == null && a.FolderId == null);
|
||||
dto.PublicAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Public);
|
||||
dto.ProtectedAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Protected);
|
||||
dto.PrivateAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Private);
|
||||
|
||||
dto.AssetsMissingMetadata = context.Assets.Count(a =>
|
||||
a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null);
|
||||
|
||||
public async Task<StatsDto> GetStatsAsync(CancellationToken cancellationToken) {
|
||||
var thumbnailSizeSetting = settingsRepo.Get(Settings.ThumbnailSize.AsString());
|
||||
var previewSizeSetting = settingsRepo.Get(Settings.PreviewSize.AsString());
|
||||
|
||||
var cacheKey = $"stats:{thumbnailSizeSetting?.Value}:{previewSizeSetting?.Value}";
|
||||
if (cache.TryGetValue(cacheKey, out StatsDto? cached) && cached is not null)
|
||||
return cached;
|
||||
|
||||
_ = int.TryParse(thumbnailSizeSetting?.Value, out var expectedThumbnailSize);
|
||||
_ = int.TryParse(previewSizeSetting?.Value, out var expectedPreviewSize);
|
||||
|
||||
dto.AssetsMissingThumbnail = context.Assets.Count(a =>
|
||||
(a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null);
|
||||
var stats = await ComputeStatsAsync(cancellationToken, expectedThumbnailSize, expectedPreviewSize);
|
||||
cache.Set(cacheKey, stats, CacheTtl);
|
||||
return stats;
|
||||
}
|
||||
|
||||
dto.AssetsMissingThumbnailStale = expectedThumbnailSize > 0
|
||||
? context.Assets.Count(a =>
|
||||
a.ThumbnailPath != null && a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize && a.DeletedAt == null)
|
||||
: 0;
|
||||
|
||||
dto.AssetsMissingPreviews = context.Assets.Count(a =>
|
||||
(a.PreviewPath == null || a.PreviewPath == "") && a.DeletedAt == null);
|
||||
|
||||
dto.AssetsMissingPreviewsStale = expectedPreviewSize > 0
|
||||
? context.Assets.Count(a =>
|
||||
a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize && a.DeletedAt == null)
|
||||
: 0;
|
||||
private async Task<StatsDto> ComputeStatsAsync(
|
||||
CancellationToken cancellationToken,
|
||||
int expectedThumbnailSize,
|
||||
int expectedPreviewSize) {
|
||||
var dto = new StatsDto();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
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 && !albumAssetSet.Any(aa => EF.Property<Guid?>(aa, "AssetsId") == a.Id));
|
||||
|
||||
dto.AssetsWithNoPerson = context.Assets.Count(a =>
|
||||
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.CosplayersMissingProfile = context.People.Count(p => p.ProfileAssetId == null);
|
||||
|
||||
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();
|
||||
|
||||
var resolutions = context.Assets
|
||||
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)
|
||||
.Select(a => new { a.ResolutionWidth, a.ResolutionHeight })
|
||||
.ToList();
|
||||
|
||||
dto.ResolutionDistribution = resolutions
|
||||
.GroupBy(r => ResolveBucket(r.ResolutionWidth, r.ResolutionHeight))
|
||||
.Select(g => new ResolutionBucketDto { Label = g.Key, Count = g.Count() })
|
||||
.OrderBy(r => r.Count)
|
||||
.ToList();
|
||||
|
||||
var mimeTypes = context.Assets
|
||||
var assetStats = await context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.Select(a => a.MimeType)
|
||||
.ToList();
|
||||
.GroupBy(a => 1)
|
||||
.Select(g => new {
|
||||
Total = g.Count(),
|
||||
TotalStorage = g.Sum(a => (long?)a.FileSize) ?? 0,
|
||||
Public = g.Count(a => a.Visibility == EVisibility.Public),
|
||||
Protected = g.Count(a => a.Visibility == EVisibility.Protected),
|
||||
Private = g.Count(a => a.Visibility == EVisibility.Private),
|
||||
Orphan = g.Count(a => a.FolderId == null),
|
||||
Added7d = g.Count(a => a.CreatedAt >= now.AddDays(-7)),
|
||||
Added30d = g.Count(a => a.CreatedAt >= now.AddDays(-30)),
|
||||
MissingMetadata = g.Count(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0),
|
||||
MissingThumbnail = g.Count(a => a.ThumbnailPath == null || a.ThumbnailPath == ""),
|
||||
MissingThumbnailStale = expectedThumbnailSize > 0
|
||||
? g.Count(a => a.ThumbnailPath != null && a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize)
|
||||
: 0,
|
||||
MissingPreview = g.Count(a => a.PreviewPath == null || a.PreviewPath == ""),
|
||||
MissingPreviewStale = expectedPreviewSize > 0
|
||||
? g.Count(a => a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize)
|
||||
: 0,
|
||||
MissingPhash = g.Count(a => a.Hash == emptyHash)
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
dto.FileFormatBreakdown = mimeTypes
|
||||
.GroupBy(m => m)
|
||||
if (assetStats is not null) {
|
||||
dto.TotalAssets = assetStats.Total;
|
||||
dto.TotalStorageBytes = assetStats.TotalStorage;
|
||||
dto.PublicAssets = assetStats.Public;
|
||||
dto.ProtectedAssets = assetStats.Protected;
|
||||
dto.PrivateAssets = assetStats.Private;
|
||||
dto.OrphanAssets = assetStats.Orphan;
|
||||
dto.AssetsAddedLast7Days = assetStats.Added7d;
|
||||
dto.AssetsAddedLast30Days = assetStats.Added30d;
|
||||
dto.AssetsMissingMetadata = assetStats.MissingMetadata;
|
||||
dto.AssetsMissingThumbnail = assetStats.MissingThumbnail;
|
||||
dto.AssetsMissingThumbnailStale = assetStats.MissingThumbnailStale;
|
||||
dto.AssetsMissingPreviews = assetStats.MissingPreview;
|
||||
dto.AssetsMissingPreviewsStale = assetStats.MissingPreviewStale;
|
||||
dto.AssetsMissingPhash = assetStats.MissingPhash;
|
||||
}
|
||||
|
||||
var byType = await context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.GroupBy(a => a.Type)
|
||||
.Select(g => new { g.Key, Count = g.Count(), Size = g.Sum(a => (long?)a.FileSize) ?? 0 })
|
||||
.ToListAsync(cancellationToken);
|
||||
dto.AssetsByType = byType.ToDictionary(x => x.Key, x => x.Count);
|
||||
dto.StorageByType = byType.ToDictionary(x => x.Key, x => x.Size);
|
||||
|
||||
var userStats = await context.Users
|
||||
.Where(u => u.DeletedAt == null)
|
||||
.GroupBy(u => u.AccessLevel)
|
||||
.Select(g => new { g.Key, Count = g.Count(), Registered30 = g.Count(u => u.CreatedAt >= now.AddDays(-30)) })
|
||||
.ToListAsync(cancellationToken);
|
||||
dto.UsersByAccessLevel = userStats.ToDictionary(x => x.Key, x => x.Count);
|
||||
dto.TotalUsers = userStats.Sum(x => x.Count);
|
||||
dto.UsersRegisteredLast30Days = userStats.Sum(x => x.Registered30);
|
||||
|
||||
dto.TotalAlbums = await context.Albums.CountAsync(cancellationToken);
|
||||
dto.TotalTags = await context.Tags.CountAsync(cancellationToken);
|
||||
dto.TotalPeople = await context.People.CountAsync(cancellationToken);
|
||||
dto.TotalFaces = await context.Faces.CountAsync(cancellationToken);
|
||||
dto.TotalFolders = await context.Folders.CountAsync(cancellationToken);
|
||||
dto.AlbumsMissingCover = await context.Albums.CountAsync(a => a.CoverAssetId == null, cancellationToken);
|
||||
dto.CosplayersMissingProfile = await context.People.CountAsync(p => p.ProfileAssetId == null, cancellationToken);
|
||||
|
||||
var albumAssetIds = context.Albums.SelectMany(al => al.Assets!).Select(a => a.Id).Distinct();
|
||||
dto.AssetsWithNoAlbum = await context.Assets.CountAsync(
|
||||
a => a.DeletedAt == null && !albumAssetIds.Contains(a.Id), cancellationToken);
|
||||
|
||||
var personAlbumAssetIds = context.Albums
|
||||
.Where(al => al.PersonOwnerId != null)
|
||||
.SelectMany(al => al.Assets!)
|
||||
.Select(a => a.Id)
|
||||
.Distinct();
|
||||
dto.AssetsWithNoPerson = await context.Assets.CountAsync(
|
||||
a => a.DeletedAt == null && !personAlbumAssetIds.Contains(a.Id), cancellationToken);
|
||||
|
||||
dto.TopTags = await context.Tags
|
||||
.Select(t => new TagStatDto {
|
||||
Id = t.Id,
|
||||
Name = t.Name,
|
||||
AssetCount = t.Assets!.Count(a => a.DeletedAt == null)
|
||||
})
|
||||
.OrderByDescending(t => t.AssetCount)
|
||||
.Take(10)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
dto.FileFormatBreakdown = await context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.GroupBy(a => a.MimeType)
|
||||
.Select(g => new MimeTypeStatDto { MimeType = g.Key, Count = g.Count() })
|
||||
.OrderByDescending(m => m.Count)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var maxDims = await context.Assets
|
||||
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)
|
||||
.GroupBy(a => a.ResolutionWidth > a.ResolutionHeight ? a.ResolutionWidth : a.ResolutionHeight)
|
||||
.Select(g => new { MaxDim = g.Key, Count = g.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
dto.ResolutionDistribution = maxDims
|
||||
.GroupBy(x => ResolveBucket(x.MaxDim))
|
||||
.Select(g => new ResolutionBucketDto { Label = g.Key, Count = g.Sum(x => x.Count) })
|
||||
.OrderBy(r => r.Count)
|
||||
.ToList();
|
||||
|
||||
var twelveMonthsAgo = now.AddMonths(-12);
|
||||
|
||||
var monthlyAssets = context.Assets
|
||||
var monthlyAssets = await context.Assets
|
||||
.Where(a => a.DeletedAt == null && a.CreatedAt >= twelveMonthsAgo)
|
||||
.GroupBy(a => new { a.CreatedAt.Year, a.CreatedAt.Month })
|
||||
.Select(g => new { g.Key.Year, g.Key.Month, Count = g.Count() })
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var monthlyUsers = context.Users
|
||||
var monthlyUsers = await context.Users
|
||||
.Where(u => u.CreatedAt >= twelveMonthsAgo)
|
||||
.GroupBy(u => new { u.CreatedAt.Year, u.CreatedAt.Month })
|
||||
.Select(g => new { g.Key.Year, g.Key.Month, Count = g.Count() })
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var months = Enumerable.Range(0, 12)
|
||||
.Select(i => twelveMonthsAgo.AddMonths(i + 1))
|
||||
@@ -163,17 +179,11 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => context.Dispose();
|
||||
|
||||
private static string ResolveBucket(int width, int height) {
|
||||
int maxDim = Math.Max(width, height);
|
||||
return maxDim switch {
|
||||
<= 480 => "SD (≤480p)",
|
||||
<= 720 => "HD (≤720p)",
|
||||
<= 1080 => "Full HD (≤1080p)",
|
||||
<= 2160 => "4K (≤2160p)",
|
||||
_ => "4K+"
|
||||
};
|
||||
}
|
||||
}
|
||||
private static string ResolveBucket(int maxDim) => maxDim switch {
|
||||
<= 480 => "SD (≤480p)",
|
||||
<= 720 => "HD (≤720p)",
|
||||
<= 1080 => "Full HD (≤1080p)",
|
||||
<= 2160 => "4K (≤2160p)",
|
||||
_ => "4K+"
|
||||
};
|
||||
}
|
||||
@@ -158,9 +158,19 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
logger?.LogWarning("A file system crawl job is already in progress, cannot queue another at this moment.");
|
||||
return;
|
||||
}
|
||||
// if the scheduled scan list isn't populated, fetch active folders directly from DB
|
||||
var targetFolders = folders;
|
||||
if (targetFolders == null || targetFolders.Count == 0) {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
|
||||
targetFolders = folderRepo.GetAll().Where(f => f.Active).ToList();
|
||||
}
|
||||
// queue a crawl job for each active folder
|
||||
folders?.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
|
||||
logger?.LogInformation("Queued file system crawl jobs.");
|
||||
targetFolders.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
|
||||
if (targetFolders.Count > 0)
|
||||
logger?.LogInformation("Queued {Count} file system crawl job(s).", targetFolders.Count);
|
||||
else
|
||||
logger?.LogWarning("No active folders to scan.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
"UserID": "root",
|
||||
"Password": "testOnlyDb",
|
||||
"Database": "TestDb",
|
||||
"connString":"Server=127.0.0.1;Port=3306;Database=TestDb;User Id=root;Password=testOnlyDb;"
|
||||
"connString":"Server=127.0.0.1;Port=5432;Database=TestDb;User Id=root;Password=testOnlyDb;"
|
||||
},
|
||||
"DatabaseAddress": {
|
||||
"Host": "localhost",
|
||||
"Port": 3306
|
||||
"Port": 5432
|
||||
},
|
||||
"SignKey": {
|
||||
"Key": "32_CHARACTERS_KEY_IS_REQUIRED_TO_WORK"
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
@* Generic collapsible section with clickable header and chevron toggle *@
|
||||
<div class="@Class">
|
||||
<div class="d-flex align-items-center gap-2 cursor-pointer" @onclick="Toggle" role="button">
|
||||
@if (TitleContent is not null) {
|
||||
@TitleContent
|
||||
} else if (!string.IsNullOrEmpty(Title)) {
|
||||
<span>@Title</span>
|
||||
}
|
||||
<span class="ms-auto small @(Expanded ? "bi-chevron-up" : "bi-chevron-down")"></span>
|
||||
</div>
|
||||
@if (Expanded) {
|
||||
<div class="@ContentClass">
|
||||
@ChildContent
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[Parameter] public RenderFragment? TitleContent { get; set; }
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
[Parameter] public bool InitiallyExpanded { get; set; }
|
||||
[Parameter] public string? Class { get; set; }
|
||||
[Parameter] public string? ContentClass { get; set; }
|
||||
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
|
||||
|
||||
bool Expanded { get; set; }
|
||||
|
||||
protected override void OnInitialized() {
|
||||
Expanded = InitiallyExpanded;
|
||||
}
|
||||
|
||||
async Task Toggle() {
|
||||
Expanded = !Expanded;
|
||||
await ExpandedChanged.InvokeAsync(Expanded);
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,6 @@
|
||||
|
||||
[Parameter]
|
||||
public FolderFullDto Folder { get; set; } = new FolderFullDto();
|
||||
[Parameter]
|
||||
public EventCallback<Guid> OnDeleted { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> OnDeleteRequested { get; set; }
|
||||
|
||||
@@ -106,13 +106,13 @@
|
||||
[Parameter] public IReadOnlyList<JobStatusDto>? Children { get; set; }
|
||||
|
||||
static readonly Dictionary<EJobStatus, string> _statusColors = new() {
|
||||
[EJobStatus.Queued] = "secondary",
|
||||
[EJobStatus.Running] = "primary",
|
||||
[EJobStatus.Waiting] = "info",
|
||||
[EJobStatus.Completed] = "success",
|
||||
[EJobStatus.CompletedWithErrors] = "warning",
|
||||
[EJobStatus.Failed] = "danger",
|
||||
[EJobStatus.Canceled] = "secondary",
|
||||
[EJobStatus.Queued] = "var(--status-queued)",
|
||||
[EJobStatus.Running] = "var(--status-running)",
|
||||
[EJobStatus.Waiting] = "var(--status-waiting)",
|
||||
[EJobStatus.Completed] = "var(--status-completed)",
|
||||
[EJobStatus.CompletedWithErrors] = "var(--status-completed-with-errors)",
|
||||
[EJobStatus.Failed] = "var(--status-failed)",
|
||||
[EJobStatus.Canceled] = "var(--status-canceled)",
|
||||
};
|
||||
|
||||
static readonly EJobStatus[] _segmentOrder = [
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
@* Section wrapper for a group of jobs with title and count *@
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<h6 class="mb-0 text-muted text-uppercase small">@Title @(Count is not null ? $"({Count})" : "")</h6>
|
||||
@if (Extra is not null) {
|
||||
<span class="ms-auto">@Extra</span>
|
||||
}
|
||||
</div>
|
||||
<div class="border rounded">
|
||||
@ChildContent
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Title { get; set; }
|
||||
[Parameter] public int? Count { get; set; }
|
||||
[Parameter] public RenderFragment? Extra { get; set; }
|
||||
[Parameter] public required RenderFragment ChildContent { get; set; }
|
||||
}
|
||||
@@ -151,7 +151,7 @@
|
||||
job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting
|
||||
);
|
||||
if (shouldFetch) {
|
||||
var (children, since) = await FetchChildren(job.Id, null);
|
||||
var (children, since) = await FetchChildren!(job.Id, null);
|
||||
_childrenCache[job.Id] = children;
|
||||
_sinceTimestamps[job.Id] = since ?? DateTime.UtcNow;
|
||||
}
|
||||
@@ -200,7 +200,7 @@
|
||||
|
||||
// Level 0: poll children for all active roots (segment progress update)
|
||||
if (Level == 0 && FetchChildren is not null) {
|
||||
foreach (var root in Roots) {
|
||||
foreach (var root in Roots!) {
|
||||
if (root.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting) {
|
||||
hasActive = true;
|
||||
visited.Add(root.Id);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@implements IDisposable
|
||||
@using Butter.Dtos.User
|
||||
@using Microsoft.IdentityModel.JsonWebTokens
|
||||
@using System.Reflection
|
||||
|
||||
<NavMenu/>
|
||||
<main class="container-xxl flex-grow-1">
|
||||
@@ -12,7 +13,7 @@
|
||||
</div>
|
||||
</main>
|
||||
<footer class="container-xxl footer d-none d-sm-flex justify-content-center">
|
||||
<span class="text-primary m-auto">Footer (duh!)</span>
|
||||
<span class="text-primary m-auto">MilkyShots - © 2024 - @DateTime.Now.Year | @AppVersion</span>
|
||||
</footer>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
@@ -23,6 +24,9 @@
|
||||
|
||||
@code {
|
||||
CancellationTokenSource? _keepaliveCts;
|
||||
string AppVersion => System.Reflection.Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion ?? "unknown";
|
||||
|
||||
protected override void OnInitialized() {
|
||||
LoginService.ForceLogout += OnForceLogout;
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@inject AlbumService albumService
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject MediaService mediaService
|
||||
@inject NavigationManager navigationManager
|
||||
|
||||
<PageTitle>@(album?.Name ?? "Album")</PageTitle>
|
||||
@@ -37,7 +36,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 mt-1">
|
||||
@if (!string.IsNullOrEmpty(album.PersonName) && album.Person.HasValue) {
|
||||
@if (!string.IsNullOrEmpty(album!.PersonName) && album.Person.HasValue) {
|
||||
<a class="album-cosplayer-link" href="/cosplayer/@album.Person.Value">@album.PersonName</a>
|
||||
}
|
||||
<span class="text-muted small">@album.AssetCount assets</span>
|
||||
@@ -143,7 +142,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (album.Images.Count == 0) {
|
||||
@if (album!.Images.Count == 0) {
|
||||
<EmptyState Variant="info" Title="No assets in this album" />
|
||||
} else {
|
||||
<div class="@("album-assets-grid " + viewMode)">
|
||||
@@ -157,7 +156,7 @@
|
||||
style="aspect-ratio:@GetAspectRatio(preview)"
|
||||
@onclick="() => HandleTileClick(index, assetId)">
|
||||
@if (preview == null || preview.HasThumbnail) {
|
||||
<img src="@ThumbUrl(assetId)"
|
||||
<img src="@mediaService.ThumbUrl(assetId)"
|
||||
loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none">
|
||||
@@ -249,11 +248,8 @@
|
||||
HashSet<Guid> selectedImageIds = [];
|
||||
Guid? selectedAssetId;
|
||||
int selectedIndex = -1;
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
string viewMode = "masonry";
|
||||
bool _masonrySetup;
|
||||
|
||||
void SetMasonry() { viewMode = "masonry"; StateHasChanged(); }
|
||||
void SetGrid() { viewMode = "grid"; StateHasChanged(); }
|
||||
@@ -292,21 +288,21 @@
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
await LoadAlbum();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (album != null && album.Images.Count > 0 && viewMode == "masonry") {
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.apply", ".album-assets-grid.masonry");
|
||||
if (viewMode == "masonry" && !_masonrySetup && album != null && album.Images.Count > 0) {
|
||||
_masonrySetup = true;
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.setup", ".album-assets-grid.masonry");
|
||||
} else if (viewMode != "masonry" && _masonrySetup) {
|
||||
_masonrySetup = false;
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized() {
|
||||
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
|
||||
loginService.AuthInfoChanged += (_, info) => {
|
||||
token = info?.Token ?? string.Empty;
|
||||
};
|
||||
}
|
||||
|
||||
async Task LoadAlbum() {
|
||||
@@ -499,10 +495,8 @@
|
||||
return "4 / 3";
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.dispose", ".album-assets-grid.masonry");
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
.album-assets-grid.masonry .masonry-tile {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.album-assets-grid.grid .masonry-tile {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@using System.Globalization
|
||||
@inject PersonService personService
|
||||
@inject AlbumService albumService
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject MediaService mediaService
|
||||
@inject NavigationManager navigationManager
|
||||
|
||||
<PageTitle>@(person?.Name ?? "Cosplayer")</PageTitle>
|
||||
@@ -40,7 +39,7 @@
|
||||
var cy = person.ProfileCropY ?? 50;
|
||||
var z = person.ProfileCropZoom ?? 0.6f;
|
||||
<div class="cosplayer-avatar-clip">
|
||||
<img src="@ThumbUrl(person.ProfileAssetId.Value)" alt=""
|
||||
<img src="@mediaService.ThumbUrl(person.ProfileAssetId.Value)" alt=""
|
||||
class="cosplayer-avatar-img"
|
||||
style="width: calc(100% / @z.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy.ToString(CultureInfo.InvariantCulture) * 1%))" />
|
||||
</div>
|
||||
@@ -60,7 +59,7 @@
|
||||
<span class="text-muted small">@pub public, @prot protected, @priv private</span>
|
||||
}
|
||||
<span class="mx-1">·</span>
|
||||
<span><i class="bi bi-image"></i> @person.TotalAssets assets</span>
|
||||
<span><i class="bi bi-image"></i> @person!.TotalAssets assets</span>
|
||||
@if (person.MaintainerUserIds?.Count > 0)
|
||||
{
|
||||
<span class="mx-1">·</span>
|
||||
@@ -222,7 +221,8 @@
|
||||
SelectedIds="@selectedAlbumIds"
|
||||
OnToggleSelection="@ToggleSelectAlbum"
|
||||
LoadVersion="@albumLoadVersion"
|
||||
FetchAlbums="@FetchPersonAlbums" />
|
||||
FetchAlbums="@FetchPersonAlbums"
|
||||
OnDataLoaded="@OnGridAlbumsLoaded" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -316,8 +316,6 @@
|
||||
Func<Task>? _pendingDeleteAction;
|
||||
bool cascadeToAssets;
|
||||
EVisibility selectedAlbumVisibility = EVisibility.Public;
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
string viewMode = "masonry";
|
||||
bool showAlbumAssigner;
|
||||
List<AlbumPreviewDto>? unassignedAlbums;
|
||||
@@ -340,8 +338,6 @@
|
||||
|
||||
int albumLoadVersion;
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
List<Guid>? allAssetIds => person?.Albums?
|
||||
.SelectMany(a => Enumerable.Repeat(a.CoverAssetId ?? Guid.Empty, 1))
|
||||
.Where(id => id != Guid.Empty)
|
||||
@@ -349,32 +345,30 @@
|
||||
.ToList();
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
await LoadPerson();
|
||||
}
|
||||
|
||||
protected override void OnInitialized() {
|
||||
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
|
||||
loginService.AuthInfoChanged += (_, info) => {
|
||||
token = info?.Token ?? string.Empty;
|
||||
};
|
||||
}
|
||||
|
||||
async Task LoadPerson() {
|
||||
isLoading = true;
|
||||
person = await personService.GetByIdAsync(Id, 0, 30);
|
||||
bannerCoverUrls = person?.Albums?
|
||||
.Where(a => a.CoverAssetId.HasValue)
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.Take(3)
|
||||
.Select(a => ThumbUrl(a.CoverAssetId!.Value))
|
||||
.ToList() ?? [];
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async Task OnGridAlbumsLoaded(List<AlbumPreviewDto> albums) {
|
||||
bannerCoverUrls = albums
|
||||
.Where(a => a.CoverAssetId.HasValue)
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.Take(3)
|
||||
.Select(a => mediaService.ThumbUrl(a.CoverAssetId!.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
async Task<List<AlbumPreviewDto>?> FetchPersonAlbums(int page, int pageSize, string? search, string? sortBy, bool sortAsc) {
|
||||
var p = await personService.GetByIdAsync(Id, page, pageSize, search, sortBy, sortAsc);
|
||||
return p?.Albums;
|
||||
return await albumService.GetAlbumsAsync(page, pageSize, search, sortBy, sortAsc, personOwnerId: Id);
|
||||
}
|
||||
|
||||
void OpenEditForm() => showForm = true;
|
||||
@@ -519,6 +513,4 @@
|
||||
if (_pendingDeleteAction != null)
|
||||
await _pendingDeleteAction();
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
@page "/"
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject MediaService mediaService
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
@@ -36,7 +35,7 @@
|
||||
style="aspect-ratio:@GetAspectRatio(asset)"
|
||||
@onclick="() => OpenPreview(asset, capturedIndex)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)"
|
||||
<img src="@mediaService.ThumbUrl(asset.Id)"
|
||||
loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none">
|
||||
@@ -136,18 +135,10 @@
|
||||
ElementReference topSentinelRef;
|
||||
DotNetObjectReference<Home>? dotNetRef;
|
||||
Guid _randomSeed;
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
|
||||
loginService.AuthInfoChanged += (_, info) => {
|
||||
token = info?.Token ?? string.Empty;
|
||||
StateHasChanged();
|
||||
};
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
loginService.AuthInfoChanged += (_, _) => StateHasChanged();
|
||||
await LoadFirstPage();
|
||||
}
|
||||
|
||||
@@ -171,7 +162,7 @@
|
||||
async Task LoadFirstPage() {
|
||||
_randomSeed = Guid.NewGuid();
|
||||
isLoading = true;
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 0, 30);
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 0, 30, includeCount: false);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Add(items);
|
||||
RebuildFlatList();
|
||||
@@ -190,7 +181,7 @@
|
||||
isLoadingMore = true;
|
||||
StateHasChanged();
|
||||
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, currentPage, 30);
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, currentPage, 30, includeCount: false);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Add(items);
|
||||
loadedMaxPage = currentPage;
|
||||
@@ -221,7 +212,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, pageToLoad, 30);
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, pageToLoad, 30, includeCount: false);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Insert(0, items);
|
||||
loadedMinPage = pageToLoad;
|
||||
@@ -257,6 +248,7 @@
|
||||
}
|
||||
|
||||
void NavigatePreview(int direction) {
|
||||
if (selectedAsset == null) return;
|
||||
var newIndex = selectedIndex + direction;
|
||||
if (newIndex < 0 || newIndex >= flatList.Count) return;
|
||||
var newAsset = flatList[newIndex];
|
||||
@@ -277,8 +269,6 @@
|
||||
return "4 / 3";
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
|
||||
if (dotNetRef != null) {
|
||||
|
||||
@@ -131,10 +131,10 @@
|
||||
<JobTree Jobs="_filteredSorted" FetchChildren="FetchChildren" EmptyText="" PollInterval="pollInterval" />
|
||||
@if (pastJobTotal > pastJobPageSize) {
|
||||
<div class="d-flex align-items-center justify-content-center gap-2 py-1 border-top">
|
||||
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage <= 1)" @onclick="() => LoadPastJobs(pastJobPage - 1)">
|
||||
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage <= 0)" @onclick="() => LoadPastJobs(pastJobPage - 1)">
|
||||
<i class="bi bi-chevron-left"></i> Prev
|
||||
</button>
|
||||
<span class="small text-muted">@pastJobPage / @pastJobTotalPages</span>
|
||||
<span class="small text-muted">@(pastJobPage + 1) / @pastJobTotalPages</span>
|
||||
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage >= pastJobTotalPages)" @onclick="() => LoadPastJobs(pastJobPage + 1)">
|
||||
Next <i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
@@ -155,7 +155,7 @@
|
||||
|
||||
List<JobStatusDto> activeRoots = [];
|
||||
List<JobStatusDto> pastJobRoots = [];
|
||||
int pastJobPage = 1;
|
||||
int pastJobPage = 0;
|
||||
int pastJobPageSize = 15;
|
||||
int pastJobTotal = 0;
|
||||
int pastJobTotalPages => Math.Max(1, (int)Math.Ceiling((double)pastJobTotal / pastJobPageSize));
|
||||
@@ -213,11 +213,11 @@
|
||||
return;
|
||||
}
|
||||
LoginService.LoggedUserChanged += async (_, _) => {
|
||||
await RefreshActiveRoots();
|
||||
await LoadPastJobs(1);
|
||||
await RefreshActiveRoots();
|
||||
await LoadPastJobs(0);
|
||||
};
|
||||
await RefreshActiveRoots();
|
||||
await LoadPastJobs(1);
|
||||
await LoadPastJobs(0);
|
||||
StartPolling();
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
StartPolling();
|
||||
} else {
|
||||
StopPolling();
|
||||
await LoadPastJobs(1);
|
||||
await LoadPastJobs(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@
|
||||
await JobsService.StartJob(jobType);
|
||||
errorMessage = null;
|
||||
await RefreshActiveRoots();
|
||||
if (pastJobPage != 1) pastJobPage = 1;
|
||||
if (pastJobPage != 0) pastJobPage = 0;
|
||||
await LoadPastJobs(pastJobPage);
|
||||
} catch (HttpRequestException ex) when ((int?)ex.StatusCode == 501) {
|
||||
errorMessage = $"{jobType} is not yet implemented on the server.";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@using System.Net
|
||||
@page "/Register"
|
||||
|
||||
@inject LoginService loginService
|
||||
@@ -5,30 +6,39 @@
|
||||
|
||||
<PageTitle>Register</PageTitle>
|
||||
|
||||
<div class="d-flex flex-column mx-sm-auto mx-3">
|
||||
@if (!string.IsNullOrEmpty(_error)) {
|
||||
<div class="m-2 p-1 border-danger border-2 rounded-3 bg-danger-subtle">
|
||||
<div class="text-danger-emphasis m-2">@_error</div>
|
||||
</div>
|
||||
}
|
||||
<form class="d-flex flex-column align-items-center">
|
||||
<input type="text" class="form-control my-1" placeholder="Username" required="required" id="username"
|
||||
@bind="_username" @bind:event="oninput"/>
|
||||
<input type="email" class="form-control my-1" placeholder="Email" required="required" id="email"
|
||||
@bind="_email" @bind:event="oninput"/>
|
||||
@if (_checkingStatus) {
|
||||
<LoadSpinner/>
|
||||
} else if (_registrationDisabled) {
|
||||
<EmptyState Variant="warning" Title="Registration Closed">
|
||||
<p>New user registration is currently disabled on this server.</p>
|
||||
<a href="/login" class="btn btn-primary">Go to Login</a>
|
||||
</EmptyState>
|
||||
} else {
|
||||
<div class="d-flex flex-column mx-sm-auto mx-3">
|
||||
@if (!string.IsNullOrEmpty(_error)) {
|
||||
<div class="m-2 p-1 border-danger border-2 rounded-3 bg-danger-subtle">
|
||||
<div class="text-danger-emphasis m-2">@_error</div>
|
||||
</div>
|
||||
}
|
||||
<form class="d-flex flex-column align-items-center">
|
||||
<input type="text" class="form-control my-1" placeholder="Username" required="required" id="username"
|
||||
@bind="_username" @bind:event="oninput"/>
|
||||
<input type="email" class="form-control my-1" placeholder="Email" required="required" id="email"
|
||||
@bind="_email" @bind:event="oninput"/>
|
||||
|
||||
<PasswordField @ref="_passwordField"
|
||||
@bind-Password="_password"
|
||||
@bind-ConfirmPassword="_confirmPassword" />
|
||||
<PasswordField @ref="_passwordField"
|
||||
@bind-Password="_password"
|
||||
@bind-ConfirmPassword="_confirmPassword" />
|
||||
|
||||
<div class="d-flex flex-row justify-content-around w-100 mt-2">
|
||||
<button class="btn btn-primary m-2" type="button" id="register-button"
|
||||
disabled="@(!CanSubmit)" @onclick="Register_OnClick">Register</button>
|
||||
<button class="btn btn-primary m-2" type="button" id="login-button"
|
||||
@onclick="Login_OnClick">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="d-flex flex-row justify-content-around w-100 mt-2">
|
||||
<button class="btn btn-primary m-2" type="button" id="register-button"
|
||||
disabled="@(!CanSubmit)" @onclick="Register_OnClick">Register</button>
|
||||
<button class="btn btn-primary m-2" type="button" id="login-button"
|
||||
@onclick="Login_OnClick">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
PasswordField? _passwordField;
|
||||
@@ -37,18 +47,29 @@
|
||||
string _password = string.Empty;
|
||||
string _confirmPassword = string.Empty;
|
||||
string _error = string.Empty;
|
||||
bool _checkingStatus = true;
|
||||
bool _registrationDisabled;
|
||||
|
||||
bool CanSubmit => _passwordField?.IsValid == true
|
||||
&& !string.IsNullOrWhiteSpace(_username)
|
||||
&& !string.IsNullOrWhiteSpace(_email);
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
_registrationDisabled = !await loginService.IsRegistrationEnabledAsync();
|
||||
_checkingStatus = false;
|
||||
}
|
||||
|
||||
void Login_OnClick() => navigation.NavigateTo("login");
|
||||
|
||||
async Task Register_OnClick() {
|
||||
_error = string.Empty;
|
||||
var ok = await loginService.Register(_username, _email, _password);
|
||||
if (ok) {
|
||||
var status = await loginService.Register(_username, _email, _password);
|
||||
if (status == HttpStatusCode.OK) {
|
||||
navigation.NavigateTo("login");
|
||||
} else if (status == HttpStatusCode.Forbidden) {
|
||||
_error = "Registration is currently disabled.";
|
||||
} else if (status == HttpStatusCode.Conflict) {
|
||||
_error = "Username or email already taken.";
|
||||
} else {
|
||||
_error = "Registration failed. Please try again.";
|
||||
}
|
||||
|
||||
@@ -18,158 +18,161 @@
|
||||
|
||||
@* ---- Top-level counts ---- *@
|
||||
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-7 g-3 mb-4">
|
||||
<div class="col"><div class="card text-bg-primary"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalAssets.ToString("N0")</h5><small>Assets</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-success"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalUsers.ToString("N0")</h5><small>Users</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-info"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalAlbums.ToString("N0")</h5><small>Albums</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-warning"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalTags.ToString("N0")</h5><small>Tags</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-secondary"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalPeople.ToString("N0")</h5><small>People</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-dark"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalFaces.ToString("N0")</h5><small>Faces</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-dark"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalFolders.ToString("N0")</h5><small>Folders</small></div></div></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="primary" Value="@_stats.TotalAssets.ToString("N0")" Label="Assets"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="success" Value="@_stats.TotalUsers.ToString("N0")" Label="Users"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="info" Value="@_stats.TotalAlbums.ToString("N0")" Label="Albums"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="warning" Value="@_stats.TotalTags.ToString("N0")" Label="Tags"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="secondary" Value="@_stats.TotalPeople.ToString("N0")" Label="People"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="dark" Value="@_stats.TotalFaces.ToString("N0")" Label="Faces"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="dark" Value="@_stats.TotalFolders.ToString("N0")" Label="Folders"/></div>
|
||||
</div>
|
||||
|
||||
@* ---- Recent activity ---- *@
|
||||
<div class="row row-cols-1 row-cols-md-3 g-3 mb-4">
|
||||
<div class="col"><div class="card border-primary"><div class="card-body"><h6 class="card-title"><i class="bi bi-calendar-week"></i> Assets (last 7 days)</h6><h4 class="text-primary">@_stats.AssetsAddedLast7Days.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-success"><div class="card-body"><h6 class="card-title"><i class="bi bi-calendar-month"></i> Assets (last 30 days)</h6><h4 class="text-success">@_stats.AssetsAddedLast30Days.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-info"><div class="card-body"><h6 class="card-title"><i class="bi bi-person-plus"></i> New Users (last 30 days)</h6><h4 class="text-info">@_stats.UsersRegisteredLast30Days.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="primary" Icon="bi bi-calendar-week" Label="Assets (last 7 days)" Value="@_stats.AssetsAddedLast7Days.ToString("N0")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="success" Icon="bi bi-calendar-month" Label="Assets (last 30 days)" Value="@_stats.AssetsAddedLast30Days.ToString("N0")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="info" Icon="bi bi-person-plus" Label="New Users (last 30 days)" Value="@_stats.UsersRegisteredLast30Days.ToString("N0")"/></div>
|
||||
</div>
|
||||
|
||||
@* ---- Data completeness indicators ---- *@
|
||||
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-4 g-3 mb-4">
|
||||
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingMetadata.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingMetadata)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Metadata</small></div></div></div>
|
||||
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingThumbnail.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingThumbnail)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Thumbnail</small><br/><small class="text-danger">@_stats.AssetsMissingThumbnailStale.ToString("N0") stale</small></div></div></div>
|
||||
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingPreviews.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingPreviews)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Previews</small><br/><small class="text-danger">@_stats.AssetsMissingPreviewsStale.ToString("N0") stale</small></div></div></div>
|
||||
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingPhash.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingPhash)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing pHash</small></div></div></div>
|
||||
<div class="col"><div class="card border-info h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsWithNoPerson.ToString("N0")</h5><small class="text-info"><i class="bi bi-info-circle"></i> No Person</small></div></div></div>
|
||||
<div class="col"><div class="card border-info h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsWithNoAlbum.ToString("N0")</h5><small class="text-info"><i class="bi bi-info-circle"></i> No Album</small></div></div></div>
|
||||
<div class="col"><div class="card border-danger h-100"><div class="card-body text-center py-3"><h5>@_stats.AlbumsMissingCover.ToString("N0")</h5><small class="text-danger"><i class="bi bi-image"></i> Albums Missing Cover</small></div></div></div>
|
||||
<div class="col"><div class="card border-secondary h-100"><div class="card-body text-center py-3"><h5>@_stats.CosplayersMissingProfile.ToString("N0")</h5><small class="text-muted"><i class="bi bi-person-badge"></i> Cosplayers Missing Profile</small></div></div></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingMetadata.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingMetadata, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Metadata"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingThumbnail.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingThumbnail, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Thumbnail" SubLabel="@($"{_stats.AssetsMissingThumbnailStale.ToString("N0")} stale")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingPreviews.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingPreviews, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Previews" SubLabel="@($"{_stats.AssetsMissingPreviewsStale.ToString("N0")} stale")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingPhash.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingPhash, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing pHash"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="info" EqualHeight="true" Value="@_stats.AssetsWithNoPerson.ToString("N0")" Icon="bi bi-info-circle" Label="No Person"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="info" EqualHeight="true" Value="@_stats.AssetsWithNoAlbum.ToString("N0")" Icon="bi bi-info-circle" Label="No Album"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="danger" EqualHeight="true" Value="@_stats.AlbumsMissingCover.ToString("N0")" Icon="bi bi-image" Label="Albums Missing Cover"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="secondary" EqualHeight="true" Value="@_stats.CosplayersMissingProfile.ToString("N0")" Icon="bi bi-person-badge" Label="Cosplayers Missing Profile"/></div>
|
||||
</div>
|
||||
|
||||
@* ---- Duplicate / Orphan / Visibility ---- *@
|
||||
<div class="row row-cols-1 row-cols-md-4 g-3 mb-4">
|
||||
<div class="col"><div class="card border-secondary"><div class="card-body"><h6 class="card-title"><i class="bi bi-folder-x"></i> Orphan Assets</h6><h4 class="text-secondary">@_stats.OrphanAssets.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-success"><div class="card-body"><h6 class="card-title"><i class="bi bi-globe2"></i> Public Assets</h6><h4 class="text-success">@_stats.PublicAssets.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-warning"><div class="card-body"><h6 class="card-title"><i class="bi bi-shield-lock"></i> Protected Assets</h6><h4 class="text-warning">@_stats.ProtectedAssets.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-danger"><div class="card-body"><h6 class="card-title"><i class="bi bi-lock"></i> Private Assets</h6><h4 class="text-danger">@_stats.PrivateAssets.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="secondary" Icon="bi bi-folder-x" Label="Orphan Assets" Value="@_stats.OrphanAssets.ToString("N0")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="success" Icon="bi bi-globe2" Label="Public Assets" Value="@_stats.PublicAssets.ToString("N0")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="warning" Icon="bi bi-shield-lock" Label="Protected Assets" Value="@_stats.ProtectedAssets.ToString("N0")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="danger" Icon="bi bi-lock" Label="Private Assets" Value="@_stats.PrivateAssets.ToString("N0")"/></div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
@* ---- Assets by type + Storage side by side ---- *@
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="bi bi-collection"></i> Assets by Type</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Type</th><th class="text-end">Count</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var kv in _stats.AssetsByType.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-hdd-stack"></i> Storage by Type</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Type</th><th class="text-end">Size</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var kv in _stats.StorageByType.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@FormatBytes(kv.Value)</td></tr>
|
||||
}
|
||||
<tr class="table-active fw-bold"><td>Total</td><td class="text-end">@FormatBytes(_stats.TotalStorageBytes)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<StatTable Title="Assets by Type" Icon="bi bi-collection" MarginBottom="true">
|
||||
<HeaderRow><th>Type</th><th class="text-end">Count</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var kv in _stats.AssetsByType.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
<StatTable Title="Storage by Type" Icon="bi bi-hdd-stack">
|
||||
<HeaderRow><th>Type</th><th class="text-end">Size</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var kv in _stats.StorageByType.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@Formatting.FormatBytes(kv.Value)</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
<FooterRow>
|
||||
<tr class="table-active fw-bold"><td>Total</td><td class="text-end">@Formatting.FormatBytes(_stats.TotalStorageBytes)</td></tr>
|
||||
</FooterRow>
|
||||
</StatTable>
|
||||
</div>
|
||||
|
||||
@* ---- Users by role + File format breakdown ---- *@
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="bi bi-people"></i> Users by Role</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Role</th><th class="text-end">Count</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var kv in _stats.UsersByAccessLevel.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-file-earmark-code"></i> File Formats</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>MIME Type</th><th class="text-end">Count</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var fmt in _stats.FileFormatBreakdown.Take(20)) {
|
||||
<tr><td><code>@fmt.MimeType</code></td><td class="text-end">@fmt.Count.ToString("N0")</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<StatTable Title="Users by Role" Icon="bi bi-people" MarginBottom="true">
|
||||
<HeaderRow><th>Role</th><th class="text-end">Count</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var kv in _stats.UsersByAccessLevel.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
<StatTable Title="File Formats" Icon="bi bi-file-earmark-code">
|
||||
<HeaderRow><th>MIME Type</th><th class="text-end">Count</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var fmt in _stats.FileFormatBreakdown.Take(20)) {
|
||||
<tr><td><code>@fmt.MimeType</code></td><td class="text-end">@fmt.Count.ToString("N0")</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
</div>
|
||||
|
||||
@* ---- Resolution distribution ---- *@
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-bounding-box-circles"></i> Resolution Distribution</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Bucket</th><th class="text-end">Count</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var bucket in _stats.ResolutionDistribution) {
|
||||
<tr><td>@bucket.Label</td><td class="text-end">@bucket.Count.ToString("N0")</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<StatTable Title="Resolution Distribution" Icon="bi bi-bounding-box-circles">
|
||||
<HeaderRow><th>Bucket</th><th class="text-end">Count</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var bucket in _stats.ResolutionDistribution) {
|
||||
<tr><td>@bucket.Label</td><td class="text-end">@bucket.Count.ToString("N0")</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
</div>
|
||||
|
||||
@* ---- Top tags ---- *@
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-tags"></i> Top Tags</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Tag</th><th class="text-end">Assets</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var tag in _stats.TopTags) {
|
||||
<tr><td>@tag.Name</td><td class="text-end">@tag.AssetCount.ToString("N0")</td></tr>
|
||||
}
|
||||
@if (!_stats.TopTags.Any()) {
|
||||
<tr><td colspan="2" class="text-muted text-center">No tags yet.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<StatTable Title="Top Tags" Icon="bi bi-tags">
|
||||
<HeaderRow><th>Tag</th><th class="text-end">Assets</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var tag in _stats.TopTags) {
|
||||
<tr><td>@tag.Name</td><td class="text-end">@tag.AssetCount.ToString("N0")</td></tr>
|
||||
}
|
||||
@if (!_stats.TopTags.Any()) {
|
||||
<tr><td colspan="2" class="text-muted text-center">No tags yet.</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
</div>
|
||||
|
||||
@* ---- Monthly growth ---- *@
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<StatTable Title="Monthly Growth (last 12 months)" Icon="bi bi-graph-up-arrow">
|
||||
<HeaderRow><th>Month</th><th class="text-end">New Assets</th><th class="text-end">New Users</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var m in _stats.MonthlyGrowth) {
|
||||
<tr>
|
||||
<td>@(new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM"))</td>
|
||||
<td class="text-end">@m.NewAssets.ToString("N0")</td>
|
||||
<td class="text-end">@m.NewUsers.ToString("N0")</td>
|
||||
</tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ---- Charts ---- *@
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-hdd-stack"></i> Storage by Type</div>
|
||||
<div class="card-body">
|
||||
<StatChart Type="bar" ChartId="chart-storage" Labels="@StorageChartLabels" Datasets="@StorageChartDatasets" FormatYAsBytes="true"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-file-earmark-code"></i> File Formats</div>
|
||||
<div class="card-body">
|
||||
<StatChart Type="doughnut" ChartId="chart-formats" Labels="@FormatChartLabels" Datasets="@FormatChartDatasets"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-bounding-box-circles"></i> Resolution Distribution</div>
|
||||
<div class="card-body">
|
||||
<StatChart Type="bar" ChartId="chart-resolution" Labels="@ResolutionChartLabels" Datasets="@ResolutionChartDatasets"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-graph-up-arrow"></i> Monthly Growth (last 12 months)</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Month</th><th class="text-end">New Assets</th><th class="text-end">New Users</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var m in _stats.MonthlyGrowth) {
|
||||
<tr>
|
||||
<td>@(new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM"))</td>
|
||||
<td class="text-end">@m.NewAssets.ToString("N0")</td>
|
||||
<td class="text-end">@m.NewUsers.ToString("N0")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card-body">
|
||||
<StatChart Type="line" ChartId="chart-growth" Labels="@GrowthChartLabels" Datasets="@GrowthChartDatasets"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,6 +184,51 @@
|
||||
private StatsDto? _stats;
|
||||
private bool _loading = true;
|
||||
|
||||
private static readonly string[] Palette = [
|
||||
"#0d6efd", "#198754", "#0dcaf0", "#ffc107", "#fd7e14", "#dc3545", "#6f42c1", "#20c997", "#6610f2", "#d63384"
|
||||
];
|
||||
|
||||
private List<string> StorageChartLabels =>
|
||||
_stats!.StorageByType.OrderByDescending(x => x.Value).Select(x => x.Key.ToString()).ToList();
|
||||
|
||||
private List<StatChart.ChartDataset> StorageChartDatasets => [
|
||||
new() {
|
||||
Label = "Storage",
|
||||
Data = _stats!.StorageByType.OrderByDescending(x => x.Value).Select(x => (double)x.Value).ToList(),
|
||||
BackgroundColor = Palette.Take(_stats!.StorageByType.Count).ToList()
|
||||
}
|
||||
];
|
||||
|
||||
private List<string> FormatChartLabels =>
|
||||
_stats!.FileFormatBreakdown.Take(8).Select(x => x.MimeType).ToList();
|
||||
|
||||
private List<StatChart.ChartDataset> FormatChartDatasets => [
|
||||
new() {
|
||||
Label = "Assets",
|
||||
Data = _stats!.FileFormatBreakdown.Take(8).Select(x => (double)x.Count).ToList(),
|
||||
BackgroundColor = Palette.Take(_stats!.FileFormatBreakdown.Take(8).Count()).ToList()
|
||||
}
|
||||
];
|
||||
|
||||
private List<string> ResolutionChartLabels =>
|
||||
_stats!.ResolutionDistribution.Select(x => x.Label).ToList();
|
||||
|
||||
private List<StatChart.ChartDataset> ResolutionChartDatasets => [
|
||||
new() {
|
||||
Label = "Assets",
|
||||
Data = _stats!.ResolutionDistribution.Select(x => (double)x.Count).ToList(),
|
||||
BackgroundColor = Palette.Take(_stats!.ResolutionDistribution.Count).ToList()
|
||||
}
|
||||
];
|
||||
|
||||
private List<string> GrowthChartLabels =>
|
||||
_stats!.MonthlyGrowth.Select(m => new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM")).ToList();
|
||||
|
||||
private List<StatChart.ChartDataset> GrowthChartDatasets => [
|
||||
new() { Label = "New Assets", Data = _stats!.MonthlyGrowth.Select(m => (double)m.NewAssets).ToList(), BorderColor = "#0d6efd", Fill = false, Tension = 0.2 },
|
||||
new() { Label = "New Users", Data = _stats!.MonthlyGrowth.Select(m => (double)m.NewUsers).ToList(), BorderColor = "#198754", Fill = false, Tension = 0.2 }
|
||||
];
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
await LoadStats();
|
||||
}
|
||||
@@ -193,14 +241,4 @@
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private string PercentOfTotal(int count) =>
|
||||
_stats?.TotalAssets > 0 ? $"{(double)count / _stats.TotalAssets * 100:F1}%" : "0%";
|
||||
|
||||
private static string FormatBytes(long bytes) => bytes switch {
|
||||
>= 1_073_741_824 => $"{bytes / 1_073_741_824.0:F2} GB",
|
||||
>= 1_048_576 => $"{bytes / 1_048_576.0:F2} MB",
|
||||
>= 1_024 => $"{bytes / 1_024.0:F2} KB",
|
||||
_ => $"{bytes} B"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,19 @@
|
||||
<div class="progress-stacked" style="height: @Height">
|
||||
@foreach (var seg in Segments) {
|
||||
<div class="progress" role="progressbar" style="width: @((seg.Width * 100).ToString(CultureInfo.InvariantCulture))%">
|
||||
<div class="progress-bar @(SegmentClass(seg.Color))"></div>
|
||||
<div class="progress-bar @(SegmentClass(seg.Color))" style="@(SegmentStyle(seg.Color))"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="progress" role="progressbar" style="height: @Height">
|
||||
<div class="progress-bar @BgClass" style="width: @((Value * 100).ToString(CultureInfo.InvariantCulture))%">
|
||||
@if (ShowPercent) { @($"{Value * 100:F0}%") }
|
||||
</div>
|
||||
<div class="progress-bar @BgClass" style="width: @((Value * 100).ToString(CultureInfo.InvariantCulture))%"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public float Value { get; set; }
|
||||
[Parameter] public string? Color { get; set; }
|
||||
[Parameter] public bool ShowPercent { get; set; }
|
||||
[Parameter] public string Size { get; set; } = "sm";
|
||||
[Parameter] public IReadOnlyList<(float Width, string Color)>? Segments { get; set; }
|
||||
|
||||
@@ -32,7 +29,9 @@
|
||||
|
||||
string BgClass => ToCssClass(Color ?? "primary");
|
||||
|
||||
string SegmentClass(string color) => ToCssClass(color);
|
||||
string SegmentClass(string color) => color.StartsWith("var(--") ? "" : ToCssClass(color);
|
||||
|
||||
string SegmentStyle(string color) => color.StartsWith("var(--") ? $"background-color: {color}" : "";
|
||||
|
||||
static string ToCssClass(string name) => name switch {
|
||||
"primary" => "text-bg-primary",
|
||||
|
||||
@@ -124,30 +124,8 @@ public static class RegexHighlighter
|
||||
return new MarkupString(sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the set of CSS class names used by the highlighter for embedding in isolated CSS.
|
||||
/// </summary>
|
||||
public static string CssClasses => string.Join(" ",
|
||||
ClassAnchor, ClassQuantifier, ClassCharClass, ClassEscape,
|
||||
ClassNamedGroup, ClassGroupRef, ClassOperator, ClassLiteral);
|
||||
|
||||
private static int FindNamedGroupEnd(ReadOnlySpan<char> span, int start)
|
||||
{
|
||||
int depth = 1;
|
||||
int i = start + 1;
|
||||
while (i < span.Length && depth > 0)
|
||||
{
|
||||
if (span[i] == '\\')
|
||||
{
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (span[i] == '(') depth++;
|
||||
if (span[i] == ')') depth--;
|
||||
i++;
|
||||
}
|
||||
return Math.Min(i - 1, span.Length - 1);
|
||||
}
|
||||
private static int FindNamedGroupEnd(ReadOnlySpan<char> span, int start) =>
|
||||
FindMatchingParen(span, start);
|
||||
|
||||
private static int FindMatchingParen(ReadOnlySpan<char> span, int start)
|
||||
{
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@implements IDisposable
|
||||
|
||||
@inject AssetService assetService
|
||||
@inject AlbumService albumService
|
||||
@inject PersonService personService
|
||||
@inject LoginService loginService
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject LoginService loginService
|
||||
@inject MediaService mediaService
|
||||
|
||||
@if (Embedded) {
|
||||
<div class="picker-embedded">
|
||||
@@ -86,7 +85,7 @@
|
||||
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
|
||||
} else {
|
||||
@@ -137,7 +136,7 @@
|
||||
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
|
||||
} else {
|
||||
@@ -254,7 +253,7 @@
|
||||
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
|
||||
} else {
|
||||
@@ -305,7 +304,7 @@
|
||||
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
|
||||
} else {
|
||||
@@ -429,7 +428,6 @@
|
||||
string currentPath = "";
|
||||
HashSet<Guid> selectedIds = [];
|
||||
string searchQuery = string.Empty;
|
||||
string token = string.Empty;
|
||||
int currentPage;
|
||||
bool isLoading;
|
||||
bool hasMore = true;
|
||||
@@ -455,15 +453,12 @@
|
||||
const int PageSize = 150;
|
||||
|
||||
bool isAdminOrCurator => loginService.IsAdminOrCurator;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
if (Embedded) await InitializeAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
if (!Embedded && Show && !_initializing) {
|
||||
_initializing = true;
|
||||
await InitializeAsync();
|
||||
@@ -613,8 +608,6 @@
|
||||
_personSearchTimer?.Dispose();
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
|
||||
static string TruncateName(string name, int maxLen = 40) {
|
||||
if (name.Length <= maxLen) return name;
|
||||
var lastSlash = name.LastIndexOf('/');
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject LoginService loginService
|
||||
@inject LoginService loginService
|
||||
@inject MediaService mediaService
|
||||
|
||||
<div class="@GetCardClass()" @key="Album.Id">
|
||||
<div class="@GetCardClass()" @key="Album.Id" style="@GetCardStyle()">
|
||||
<a href="/albums/@Album.Id" class="album-card-overlay" aria-label="View album @Album.Name"
|
||||
@onclick="HandleOverlayClick" @onclick:preventDefault="SelectionMode"></a>
|
||||
@if (Album.CoverAssetId.HasValue) {
|
||||
<img src="@ThumbUrl(Album.CoverAssetId.Value)"
|
||||
<img src="@mediaService.ThumbUrl(Album.CoverAssetId.Value)"
|
||||
loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="album-card-fallback" style="display:none">
|
||||
@@ -51,11 +50,8 @@
|
||||
[Parameter]
|
||||
public EventCallback<Guid> OnToggleSelection { get; set; }
|
||||
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
string token => loginService.AuthInfo?.Token ?? string.Empty;
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
[Parameter]
|
||||
public string ViewMode { get; set; } = "masonry";
|
||||
|
||||
string GetCardClass() {
|
||||
var classes = new List<string> { "album-card" };
|
||||
@@ -71,6 +67,15 @@
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
string GetCardStyle() {
|
||||
if (ViewMode == "masonry") {
|
||||
var w = Album.CoverWidth ?? 3;
|
||||
var h = Album.CoverHeight ?? 4;
|
||||
return $"aspect-ratio:{w}/{h}";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async Task HandleOverlayClick() {
|
||||
if (SelectionMode)
|
||||
await OnToggleSelection.InvokeAsync(Album.Id);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
@if (isLoading) {
|
||||
<LoadSpinner/>
|
||||
} else if (albums.Count == 0) {
|
||||
} else if (buffer.Count == 0 && !hasMore) {
|
||||
<EmptyState Variant="warning" Title="No albums available">
|
||||
@if (loginService.IsLoggedIn) {
|
||||
<p>There are no albums yet.</p>
|
||||
@@ -16,20 +16,24 @@
|
||||
}
|
||||
</EmptyState>
|
||||
} else {
|
||||
@if (isLoadingUp) {
|
||||
<LoadSpinner/>
|
||||
}
|
||||
|
||||
<div class="@("album-grid " + ViewMode)">
|
||||
@foreach (var album in albums) {
|
||||
<AlbumCard Album="album"
|
||||
@foreach (var album in buffer.FlatList) {
|
||||
<AlbumCard @key="album.Id"
|
||||
Album="album"
|
||||
SelectionMode="SelectionMode"
|
||||
Selected="SelectedIds.Contains(album.Id)"
|
||||
OnToggleSelection="ToggleAlbumSelection"/>
|
||||
OnToggleSelection="ToggleAlbumSelection"
|
||||
ViewMode="@ViewMode"/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isLoadingMore) {
|
||||
<LoadSpinner/>
|
||||
}
|
||||
|
||||
<div @ref="sentinelRef" class="masonry-sentinel"></div>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -42,19 +46,22 @@
|
||||
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
|
||||
[Parameter] public int LoadVersion { get; set; }
|
||||
[Parameter] public Func<int, int, string?, string?, bool, Task<List<AlbumPreviewDto>?>>? FetchAlbums { get; set; }
|
||||
[Parameter] public EventCallback<List<AlbumPreviewDto>> OnDataLoaded { get; set; }
|
||||
|
||||
List<AlbumPreviewDto> albums = [];
|
||||
int currentPage = 0;
|
||||
PageBuffer<AlbumPreviewDto> buffer = new(a => a.Id) { MaxPages = 10 };
|
||||
bool hasMore = true;
|
||||
bool hasMoreUp => buffer.MinPage > 0;
|
||||
bool isLoading = true;
|
||||
bool isLoadingMore;
|
||||
ElementReference sentinelRef;
|
||||
bool isLoadingUp;
|
||||
DotNetObjectReference<AlbumGrid>? dotNetRef;
|
||||
|
||||
int? _prevLoadVersion;
|
||||
string? _prevSearch;
|
||||
string? _prevSortBy;
|
||||
bool _prevSortAsc;
|
||||
string? _prevViewMode;
|
||||
bool _masonrySetup;
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
bool filterChanged = _prevLoadVersion != LoadVersion
|
||||
@@ -62,6 +69,14 @@
|
||||
|| _prevSortBy != SortBy
|
||||
|| _prevSortAsc != SortAsc;
|
||||
|
||||
if (_prevViewMode == "masonry" && ViewMode != "masonry") {
|
||||
_masonrySetup = false;
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-grid.masonry");
|
||||
} else if (_prevViewMode != "masonry" && ViewMode == "masonry") {
|
||||
_masonrySetup = false;
|
||||
}
|
||||
|
||||
_prevViewMode = ViewMode;
|
||||
_prevLoadVersion = LoadVersion;
|
||||
_prevSearch = SearchQuery;
|
||||
_prevSortBy = SortBy;
|
||||
@@ -72,18 +87,22 @@
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (!isLoading && (albums.Count > 0 || hasMore) && dotNetRef == null) {
|
||||
if (!_masonrySetup && ViewMode == "masonry" && buffer.Count > 0) {
|
||||
_masonrySetup = true;
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.setup", ".album-grid.masonry");
|
||||
}
|
||||
|
||||
if (!isLoading && (buffer.Count > 0 || hasMore) && dotNetRef == null) {
|
||||
dotNetRef = DotNetObjectReference.Create(this);
|
||||
await jsRuntime.InvokeVoidAsync(
|
||||
"albumObserver.observeBottom", sentinelRef, dotNetRef
|
||||
"albumObserver.setup", dotNetRef
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async Task Reload() {
|
||||
await DisposeObserver();
|
||||
albums.Clear();
|
||||
currentPage = 0;
|
||||
buffer.Clear();
|
||||
hasMore = true;
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
@@ -92,11 +111,13 @@
|
||||
? await FetchAlbums(0, 30, SearchQuery, SortBy, SortAsc)
|
||||
: await albumService.GetAlbumsAsync(0, 30, SearchQuery, SortBy, SortAsc);
|
||||
if (items?.Count > 0) {
|
||||
albums = items;
|
||||
currentPage = 1;
|
||||
buffer.AddPage(0, items);
|
||||
}
|
||||
hasMore = items?.Count == 30;
|
||||
isLoading = false;
|
||||
|
||||
if (buffer.Count > 0)
|
||||
await OnDataLoaded.InvokeAsync(buffer.FlatList);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
@@ -105,18 +126,41 @@
|
||||
isLoadingMore = true;
|
||||
StateHasChanged();
|
||||
|
||||
var nextPage = buffer.MaxPage + 1;
|
||||
var items = FetchAlbums != null
|
||||
? await FetchAlbums(currentPage, 30, SearchQuery, SortBy, SortAsc)
|
||||
: await albumService.GetAlbumsAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
|
||||
? await FetchAlbums(nextPage, 30, SearchQuery, SortBy, SortAsc)
|
||||
: await albumService.GetAlbumsAsync(nextPage, 30, SearchQuery, SortBy, SortAsc);
|
||||
if (items?.Count > 0) {
|
||||
albums.AddRange(items);
|
||||
currentPage++;
|
||||
buffer.AddPage(nextPage, items);
|
||||
}
|
||||
hasMore = items?.Count == 30;
|
||||
isLoadingMore = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task LoadPreviousAlbums() {
|
||||
if (isLoadingUp || !hasMoreUp) return;
|
||||
isLoadingUp = true;
|
||||
StateHasChanged();
|
||||
|
||||
var prevPage = buffer.MinPage - 1;
|
||||
if (prevPage < 0) {
|
||||
isLoadingUp = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var items = FetchAlbums != null
|
||||
? await FetchAlbums(prevPage, 30, SearchQuery, SortBy, SortAsc)
|
||||
: await albumService.GetAlbumsAsync(prevPage, 30, SearchQuery, SortBy, SortAsc);
|
||||
if (items?.Count > 0) {
|
||||
buffer.PrependPage(prevPage, items);
|
||||
}
|
||||
|
||||
isLoadingUp = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
async Task ToggleAlbumSelection(Guid id) => await OnToggleSelection.InvokeAsync(id);
|
||||
|
||||
async Task DisposeObserver() {
|
||||
@@ -127,15 +171,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await DisposeObserver();
|
||||
public async ValueTask DisposeAsync() {
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-grid.masonry");
|
||||
await DisposeObserver();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the IDs of all currently loaded albums.
|
||||
/// </summary>
|
||||
public List<Guid> GetCurrentAlbumIds() => albums.Select(a => a.Id).ToList();
|
||||
public List<Guid> GetCurrentAlbumIds() => buffer.FlatList.Select(a => a.Id).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the previews of all currently loaded albums.
|
||||
/// </summary>
|
||||
public List<AlbumPreviewDto> GetCurrentAlbumPreviews() => [.. albums];
|
||||
public List<AlbumPreviewDto> GetCurrentAlbumPreviews() => [.. buffer.FlatList];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
column-count: 4;
|
||||
column-gap: 8px;
|
||||
padding: 0 8px;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
/* ── Grid ── */
|
||||
@@ -11,12 +12,14 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
/* ── Card in masonry ── */
|
||||
.album-grid.masonry ::deep .album-card {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 8px;
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.album-grid.grid ::deep .album-card {
|
||||
@@ -30,6 +33,7 @@
|
||||
|
||||
.masonry-sentinel {
|
||||
height: 1px;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
@using Microsoft.Extensions.Options
|
||||
@using MilkStream.Client.Services
|
||||
@using System.Linq
|
||||
|
||||
@inject LoginService loginService
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject MediaService mediaService
|
||||
|
||||
<ModalFrame Title="Select Cover Image" Show="Show" Size="lg" OnClose="OnCancel">
|
||||
<Body>
|
||||
@@ -15,7 +12,7 @@
|
||||
<div class="picker-tile @(SelectedAssetId == id ? "selected" : "")"
|
||||
@key="id"
|
||||
@onclick="() => SelectAsset(id)">
|
||||
<img src="@ThumbUrl(id)"
|
||||
<img src="@mediaService.ThumbUrl(id)"
|
||||
loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none">
|
||||
@@ -47,15 +44,6 @@
|
||||
[Parameter] public EventCallback<Guid?> OnSelected { get; set; }
|
||||
[Parameter] public EventCallback OnClosed { get; set; }
|
||||
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
protected override void OnParametersSet() {
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
}
|
||||
|
||||
void SelectAsset(Guid id) {
|
||||
SelectedAssetId = id;
|
||||
}
|
||||
@@ -68,6 +56,4 @@
|
||||
async Task OnCancel() {
|
||||
await OnClosed.InvokeAsync();
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@using System.Globalization
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@inject PersonService personService
|
||||
@inject LoginService loginService
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject MediaService mediaService
|
||||
@inject IJSRuntime jsRuntime
|
||||
|
||||
@if (isLoading) {
|
||||
<LoadSpinner/>
|
||||
} else if (peopleDict.Count == 0 && !hasMore) {
|
||||
} else if (buffer.Count == 0 && !hasMore) {
|
||||
<EmptyState Variant="warning" Title="No media available">
|
||||
@if (loginService.IsLoggedIn) {
|
||||
<p>There are no cosplayers yet.</p>
|
||||
@@ -20,8 +19,12 @@
|
||||
}
|
||||
</EmptyState>
|
||||
} else {
|
||||
@if (isLoadingUp) {
|
||||
<LoadSpinner/>
|
||||
}
|
||||
|
||||
<div class="cosplayers-grid">
|
||||
@foreach (var person in peopleDict.Values) {
|
||||
@foreach (var person in buffer.FlatList) {
|
||||
<a href="/cosplayer/@person.Id" class="@GetCardClass(person)" @key="person.Id"
|
||||
@onclick="() => HandleCardClick(person.Id)"
|
||||
@onclick:preventDefault="SelectionMode">
|
||||
@@ -30,7 +33,7 @@
|
||||
var cy1 = person.ProfileCropY ?? 50;
|
||||
var zoom1 = person.ProfileCropZoom ?? 0.6f;
|
||||
<div class="cosplayer-card-img-wrap">
|
||||
<img src="@ThumbUrl(person.ProfileAssetId.Value)" alt=""
|
||||
<img src="@mediaService.ThumbUrl(person.ProfileAssetId.Value)" alt=""
|
||||
class="cosplayer-card-img"
|
||||
style="width: calc(100% / @zoom1.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx1.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy1.ToString(CultureInfo.InvariantCulture) * 1%))"
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
@@ -65,8 +68,6 @@
|
||||
@if (isLoadingMore) {
|
||||
<LoadSpinner/>
|
||||
}
|
||||
|
||||
<div @ref="sentinelRef" class="masonry-sentinel"></div>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -78,26 +79,18 @@
|
||||
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
|
||||
[Parameter] public int LoadVersion { get; set; }
|
||||
|
||||
Dictionary<Guid, PersonPreviewDto> peopleDict = [];
|
||||
int currentPage = 0;
|
||||
PageBuffer<PersonPreviewDto> buffer = new(p => p.Id) { MaxPages = 10 };
|
||||
bool hasMore = true;
|
||||
bool hasMoreUp => buffer.MinPage > 0;
|
||||
bool isLoading = true;
|
||||
bool isLoadingMore;
|
||||
ElementReference sentinelRef;
|
||||
bool isLoadingUp;
|
||||
DotNetObjectReference<CosplayerGrid>? dotNetRef;
|
||||
|
||||
int? _prevLoadVersion;
|
||||
string? _prevSearch;
|
||||
string? _prevSortBy;
|
||||
bool _prevSortAsc;
|
||||
string _token = string.Empty;
|
||||
string _apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
string TokenParam => string.IsNullOrEmpty(_token) ? "" : $"?token={_token}";
|
||||
|
||||
protected override void OnInitialized() {
|
||||
loginService.AuthInfoChanged += OnAuthInfoChanged;
|
||||
_token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
bool filterChanged = _prevLoadVersion != LoadVersion
|
||||
@@ -115,27 +108,24 @@
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (!isLoading && (peopleDict.Count > 0 || hasMore) && dotNetRef == null) {
|
||||
if (!isLoading && (buffer.Count > 0 || hasMore) && dotNetRef == null) {
|
||||
dotNetRef = DotNetObjectReference.Create(this);
|
||||
await jsRuntime.InvokeVoidAsync(
|
||||
"cosplayerObserver.observeBottom", sentinelRef, dotNetRef
|
||||
"cosplayerObserver.setup", dotNetRef
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async Task Reload() {
|
||||
await DisposeObserver();
|
||||
peopleDict.Clear();
|
||||
currentPage = 0;
|
||||
buffer.Clear();
|
||||
hasMore = true;
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var items = await personService.GetAllAsync(0, 30, SearchQuery, SortBy, SortAsc);
|
||||
if (items?.Count > 0) {
|
||||
foreach (var p in items)
|
||||
peopleDict[p.Id] = p;
|
||||
currentPage = 1;
|
||||
buffer.AddPage(0, items);
|
||||
}
|
||||
hasMore = items?.Count == 30;
|
||||
isLoading = false;
|
||||
@@ -147,25 +137,43 @@
|
||||
isLoadingMore = true;
|
||||
StateHasChanged();
|
||||
|
||||
var items = await personService.GetAllAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
|
||||
var nextPage = buffer.MaxPage + 1;
|
||||
var items = await personService.GetAllAsync(nextPage, 30, SearchQuery, SortBy, SortAsc);
|
||||
if (items?.Count > 0) {
|
||||
foreach (var p in items)
|
||||
peopleDict[p.Id] = p;
|
||||
currentPage++;
|
||||
buffer.AddPage(nextPage, items);
|
||||
}
|
||||
hasMore = items?.Count == 30;
|
||||
isLoadingMore = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task LoadPreviousCosplayers() {
|
||||
if (isLoadingUp || !hasMoreUp) return;
|
||||
isLoadingUp = true;
|
||||
StateHasChanged();
|
||||
|
||||
var prevPage = buffer.MinPage - 1;
|
||||
if (prevPage < 0) {
|
||||
isLoadingUp = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var items = await personService.GetAllAsync(prevPage, 30, SearchQuery, SortBy, SortAsc);
|
||||
if (items?.Count > 0) {
|
||||
buffer.PrependPage(prevPage, items);
|
||||
}
|
||||
|
||||
isLoadingUp = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
void HandleCardClick(Guid id) {
|
||||
if (SelectionMode) {
|
||||
OnToggleSelection.InvokeAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{_apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
|
||||
string GetCardClass(PersonPreviewDto person) {
|
||||
var classes = new List<string> { "cosplayer-card" };
|
||||
if (SelectionMode) {
|
||||
@@ -179,10 +187,6 @@
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
void OnAuthInfoChanged(object? _, AuthInfo? info) {
|
||||
_token = info?.Token ?? string.Empty;
|
||||
}
|
||||
|
||||
async Task DisposeObserver() {
|
||||
if (dotNetRef != null) {
|
||||
await jsRuntime.InvokeVoidAsync("cosplayerObserver.dispose");
|
||||
@@ -192,17 +196,16 @@
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
loginService.AuthInfoChanged -= OnAuthInfoChanged;
|
||||
await DisposeObserver();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the IDs of all currently loaded cosplayers.
|
||||
/// </summary>
|
||||
public List<Guid> GetCurrentPersonIds() => [.. peopleDict.Keys];
|
||||
public List<Guid> GetCurrentPersonIds() => buffer.FlatList.Select(p => p.Id).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the previews of all currently loaded cosplayers.
|
||||
/// </summary>
|
||||
public List<PersonPreviewDto> GetCurrentPersonPreviews() => [.. peopleDict.Values];
|
||||
public List<PersonPreviewDto> GetCurrentPersonPreviews() => [.. buffer.FlatList];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.cosplayer-card {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
@using Microsoft.Extensions.Options
|
||||
|
||||
@inject IOptions<ServiceOptions> Options
|
||||
@inject LoginService LoginService
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject MediaService mediaService
|
||||
|
||||
@if (Show && AssetId != Guid.Empty) {
|
||||
<div class="preview-overlay" @onkeydown="OnKeyDown" tabindex="0" @onclick="OnCloseCallback">
|
||||
@@ -18,10 +15,10 @@
|
||||
|
||||
<div class="preview-image-wrapper">
|
||||
@if (HasPreview) {
|
||||
<img class="preview-img" src="@PreviewUrl" alt=""
|
||||
onerror="this.onerror=null;this.src='@ThumbUrl'" />
|
||||
<img class="preview-img" src="@mediaService.PreviewUrl(AssetId)" alt=""
|
||||
onerror="this.onerror=null;this.src='@mediaService.ThumbUrl(AssetId)'" />
|
||||
} else {
|
||||
<img class="preview-img" src="@ThumbUrl" alt="" />
|
||||
<img class="preview-img" src="@mediaService.ThumbUrl(AssetId)" alt="" />
|
||||
}
|
||||
<div class="preview-info">
|
||||
<div class="preview-info-text">
|
||||
@@ -32,7 +29,7 @@
|
||||
<span class="preview-info-filename">@Filename</span>
|
||||
}
|
||||
<div class="preview-info-actions">
|
||||
<a class="preview-action-btn" href="@OriginalUrl" target="_blank"
|
||||
<a class="preview-action-btn" href="@mediaService.OriginalUrl(AssetId)" target="_blank"
|
||||
title="View full size" @onclick:stopPropagation="true">
|
||||
<i class="bi bi-arrows-fullscreen"></i>
|
||||
</a>
|
||||
@@ -59,18 +56,6 @@
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
string token = string.Empty;
|
||||
string apiBase => Options.Value.BaseUrl.TrimEnd('/');
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
string ThumbUrl => $"{apiBase}/api/media/thumb/{AssetId}{TokenParam}";
|
||||
string PreviewUrl => $"{apiBase}/api/media/preview/{AssetId}{TokenParam}";
|
||||
string OriginalUrl => $"{apiBase}/api/media/original/{AssetId}{TokenParam}";
|
||||
|
||||
protected override void OnInitialized() {
|
||||
LoginService.AuthInfoChanged += (_, info) => { token = info?.Token ?? string.Empty; };
|
||||
token = LoginService.AuthInfo?.Token ?? string.Empty;
|
||||
}
|
||||
|
||||
async Task OnCloseCallback() {
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
@@ -90,6 +75,6 @@
|
||||
}
|
||||
|
||||
async Task Download() {
|
||||
await JSRuntime.InvokeVoidAsync("masonryObserver.downloadFile", OriginalUrl);
|
||||
await JSRuntime.InvokeVoidAsync("masonryObserver.downloadFile", mediaService.OriginalUrl(AssetId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
@using Microsoft.Extensions.Options
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject LoginService loginService
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject MediaService mediaService
|
||||
|
||||
@if (Show && AssetId.HasValue) {
|
||||
<div class="modal-overlay" style="background: rgba(0, 0, 0, 0.85)" @onclick="OnCancel">
|
||||
@@ -72,11 +70,6 @@
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnClosed { get; set; }
|
||||
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
string token => loginService.AuthInfo?.Token ?? string.Empty;
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
string ImageUrl => $"{apiBase}/api/media/thumb/{AssetId!.Value}{TokenParam}";
|
||||
|
||||
protected override void OnInitialized() {
|
||||
_instanceId = ++_counter;
|
||||
}
|
||||
@@ -93,7 +86,7 @@
|
||||
if (_pendingInit) {
|
||||
_pendingInit = false;
|
||||
await jsRuntime.InvokeVoidAsync("profileCropper.initialize",
|
||||
ContainerId, ImageUrl, CropX ?? 50, CropY ?? 50, CropZoom ?? 0.6f);
|
||||
ContainerId, mediaService.ThumbUrl(AssetId!.Value), CropX ?? 50, CropY ?? 50, CropZoom ?? 0.6f);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Dtos.Person
|
||||
@using Microsoft.Extensions.Options
|
||||
@using MilkStream.Client.Services
|
||||
|
||||
@namespace MilkStream.Client.Components.Shared
|
||||
|
||||
@inject PersonService PersonSvc
|
||||
@inject AlbumService AlbumSvc
|
||||
@inject NavigationManager Nav
|
||||
@inject IOptions<ServiceOptions> ServiceOptions
|
||||
@inject LoginService LoginSvc
|
||||
@inject PersonService PersonSvc
|
||||
@inject AlbumService AlbumSvc
|
||||
@inject NavigationManager Nav
|
||||
@inject MediaService mediaService
|
||||
|
||||
<div class="search-dropdown-container position-relative">
|
||||
<div class="d-flex">
|
||||
@@ -67,7 +65,7 @@
|
||||
@onmouseenter="() => OnItemHover(idx)">
|
||||
@if (item.ImageId.HasValue)
|
||||
{
|
||||
<img class="search-dropdown-thumb" src="@ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
|
||||
<img class="search-dropdown-thumb" src="@mediaService.ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -85,7 +83,7 @@
|
||||
@onmouseenter="() => OnItemHover(idx)">
|
||||
@if (item.ImageId.HasValue)
|
||||
{
|
||||
<img class="search-dropdown-thumb-rounded" src="@ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
|
||||
<img class="search-dropdown-thumb-rounded" src="@mediaService.ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -117,12 +115,6 @@
|
||||
int _selectedIndex = -1;
|
||||
CancellationTokenSource? _blurCts;
|
||||
|
||||
string ApiBase => ServiceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
string Token => LoginSvc.AuthInfo?.Token ?? string.Empty;
|
||||
string TokenParam => string.IsNullOrEmpty(Token) ? "" : $"?token={Token}";
|
||||
|
||||
string ThumbUrl(Guid id) => $"{ApiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
|
||||
async Task OnInput(ChangeEventArgs e)
|
||||
{
|
||||
_query = e.Value?.ToString() ?? string.Empty;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div style="height: @(Height)px;">
|
||||
<canvas id="@ChartId"></canvas>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// A Chart.js dataset passed to the interop renderer.
|
||||
/// </summary>
|
||||
public class ChartDataset {
|
||||
/// <summary>Dataset label shown in legends/tooltips.</summary>
|
||||
public string Label { get; set; } = "";
|
||||
/// <summary>Numeric values for the dataset.</summary>
|
||||
public List<double> Data { get; set; } = [];
|
||||
/// <summary>Per-point background colors (bar/doughnut).</summary>
|
||||
public List<string>? BackgroundColor { get; set; }
|
||||
/// <summary>Line color.</summary>
|
||||
public string? BorderColor { get; set; }
|
||||
/// <summary>Whether the area under a line chart is filled.</summary>
|
||||
public bool Fill { get; set; }
|
||||
/// <summary>Line smoothing (0 for straight segments).</summary>
|
||||
public double Tension { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Unique DOM id of the canvas. Defaults to a random value per instance.</summary>
|
||||
[Parameter] public string ChartId { get; set; } = $"stats-chart-{Guid.NewGuid():N}";
|
||||
|
||||
/// <summary>The Chart.js chart type (e.g. "bar", "doughnut", "line").</summary>
|
||||
[Parameter] public string Type { get; set; } = "bar";
|
||||
|
||||
/// <summary>Category labels for the chart.</summary>
|
||||
[Parameter] public List<string>? Labels { get; set; }
|
||||
|
||||
/// <summary>Datasets to render.</summary>
|
||||
[Parameter] public List<ChartDataset> Datasets { get; set; } = [];
|
||||
|
||||
/// <summary>Canvas container height in pixels.</summary>
|
||||
[Parameter] public int Height { get; set; } = 280;
|
||||
|
||||
/// <summary>Whether the y-axis and tooltips should format values as bytes.</summary>
|
||||
[Parameter] public bool FormatYAsBytes { get; set; }
|
||||
|
||||
private bool _rendered;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (firstRender) {
|
||||
await RenderChart();
|
||||
_rendered = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RenderChart() {
|
||||
if (Labels is null || Datasets.Count == 0)
|
||||
return;
|
||||
|
||||
await JSRuntime.InvokeVoidAsync("statsCharts.render", ChartId, new {
|
||||
type = Type,
|
||||
data = new {
|
||||
labels = Labels,
|
||||
datasets = Datasets
|
||||
},
|
||||
options = new {
|
||||
responsive = true,
|
||||
maintainAspectRatio = false
|
||||
},
|
||||
formatYAsBytes = FormatYAsBytes
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync() {
|
||||
if (_rendered) {
|
||||
try {
|
||||
await JSRuntime.InvokeVoidAsync("statsCharts.destroy", ChartId);
|
||||
} catch (JSDisconnectedException) {
|
||||
// Page navigated away; the canvas is already gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<div class="card @CardClasses">
|
||||
<div class="card-body @(Centered ? "text-center py-3" : "")">
|
||||
@if (Layout == StatCardLayout.Solid) {
|
||||
<h5 class="card-title">@Value</h5>
|
||||
<small>@Label</small>
|
||||
} else if (Layout == StatCardLayout.Outline) {
|
||||
<h5>@Value</h5>
|
||||
@if (Percent is not null) {
|
||||
<small class="text-muted">@Percent</small>
|
||||
<br/>
|
||||
}
|
||||
<small class="text-@Variant">
|
||||
@if (Icon is not null) {
|
||||
<i class="bi @Icon"></i>
|
||||
}
|
||||
@Label
|
||||
</small>
|
||||
@if (SubLabel is not null) {
|
||||
<br/>
|
||||
<small class="text-@SubLabelVariant">@SubLabel</small>
|
||||
}
|
||||
} else {
|
||||
<h6 class="card-title">
|
||||
@if (Icon is not null) {
|
||||
<i class="bi @Icon"></i>
|
||||
}
|
||||
@Label
|
||||
</h6>
|
||||
<h4 class="text-@Variant">@Value</h4>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Card layout variants.</summary>
|
||||
public enum StatCardLayout { Solid, Outline, IconTitle }
|
||||
|
||||
/// <summary>The card layout variant.</summary>
|
||||
[Parameter] public StatCardLayout Layout { get; set; } = StatCardLayout.Outline;
|
||||
|
||||
/// <summary>The displayed value (pre-formatted).</summary>
|
||||
[Parameter] public string Value { get; set; } = "";
|
||||
|
||||
/// <summary>The card label/title text.</summary>
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
|
||||
/// <summary>The Bootstrap color variant (e.g. "primary", "warning").</summary>
|
||||
[Parameter] public string Variant { get; set; } = "primary";
|
||||
|
||||
/// <summary>Optional Bootstrap Icons class (e.g. "bi bi-globe2").</summary>
|
||||
[Parameter] public string? Icon { get; set; }
|
||||
|
||||
/// <summary>Optional percentage line shown on outline cards.</summary>
|
||||
[Parameter] public string? Percent { get; set; }
|
||||
|
||||
/// <summary>Optional secondary line shown on outline cards (e.g. "N stale").</summary>
|
||||
[Parameter] public string? SubLabel { get; set; }
|
||||
|
||||
/// <summary>Bootstrap color variant for the secondary line.</summary>
|
||||
[Parameter] public string SubLabelVariant { get; set; } = "danger";
|
||||
|
||||
/// <summary>Whether to center the card body content vertically.</summary>
|
||||
[Parameter] public bool Centered { get; set; } = true;
|
||||
|
||||
/// <summary>Whether the card should stretch to fill its column height.</summary>
|
||||
[Parameter] public bool EqualHeight { get; set; }
|
||||
|
||||
private string CardClasses {
|
||||
get {
|
||||
var variant = Layout == StatCardLayout.Solid ? $"text-bg-{Variant}" : $"border-{Variant}";
|
||||
return EqualHeight ? $"{variant} h-100" : variant;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="card @(MarginBottom ? "mb-3" : "")">
|
||||
<div class="card-header"><i class="bi @Icon"></i> @Title</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead>
|
||||
<tr>@HeaderRow</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ChildContent
|
||||
@if (FooterRow is not null) {
|
||||
@FooterRow
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>The card title shown in the header.</summary>
|
||||
[Parameter] public string Title { get; set; } = "";
|
||||
|
||||
/// <summary>The Bootstrap Icons class shown in the header (e.g. "bi bi-collection").</summary>
|
||||
[Parameter] public string Icon { get; set; } = "";
|
||||
|
||||
/// <summary>The table header cells.</summary>
|
||||
[Parameter] public RenderFragment? HeaderRow { get; set; }
|
||||
|
||||
/// <summary>The table body rows.</summary>
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>Optional footer row (e.g. a bold total).</summary>
|
||||
[Parameter] public RenderFragment? FooterRow { get; set; }
|
||||
|
||||
/// <summary>Whether to add a bottom margin below the card.</summary>
|
||||
[Parameter] public bool MarginBottom { get; set; }
|
||||
}
|
||||
@@ -4,8 +4,19 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="SetGitVersion" BeforeTargets="BeforeCompile" Condition="'$(InformationalVersion)' == ''">
|
||||
<Exec Command="git describe --tags --always --dirty || echo unknown"
|
||||
ConsoleToMSBuild="true" StandardOutputImportance="low">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="GitVersion" />
|
||||
</Exec>
|
||||
<PropertyGroup>
|
||||
<InformationalVersion>$(GitVersion.Trim())</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.SassCompiler" Version="1.101.0" />
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
namespace MilkStream.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a sliding-window buffer of paginated data.
|
||||
/// Pages are added to the front or back, and the buffer is capped at <see cref="MaxPages"/>.
|
||||
/// When the cap is exceeded, the oldest page is dropped from the opposite end.
|
||||
/// </summary>
|
||||
public class PageBuffer<T> where T : class {
|
||||
readonly List<List<T>> _pages = [];
|
||||
readonly Func<T, Guid> _keySelector;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="PageBuffer{T}"/> with the given key selector.
|
||||
/// </summary>
|
||||
/// <param name="keySelector">A function that extracts the deduplication key from each item.</param>
|
||||
public PageBuffer(Func<T, Guid> keySelector) {
|
||||
_keySelector = keySelector;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the page index of the first page in the buffer.
|
||||
/// </summary>
|
||||
public int MinPage { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the page index of the last page in the buffer.
|
||||
/// </summary>
|
||||
public int MaxPage { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of pages to keep in the buffer.
|
||||
/// Defaults to 10.
|
||||
/// </summary>
|
||||
public int MaxPages { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the flattened, deduplicated list of all items currently in the buffer.
|
||||
/// </summary>
|
||||
public List<T> FlatList { get; private set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of items in the flat list.
|
||||
/// </summary>
|
||||
public int Count => FlatList.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a page to the back of the buffer. Drops the front page if over cap.
|
||||
/// </summary>
|
||||
public void AddPage(int pageIndex, List<T> items) {
|
||||
_pages.Add(items);
|
||||
MaxPage = pageIndex;
|
||||
if (_pages.Count > MaxPages) {
|
||||
_pages.RemoveAt(0);
|
||||
MinPage++;
|
||||
}
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a page to the front of the buffer. Drops the back page if over cap.
|
||||
/// </summary>
|
||||
public void PrependPage(int pageIndex, List<T> items) {
|
||||
_pages.Insert(0, items);
|
||||
MinPage = pageIndex;
|
||||
if (_pages.Count > MaxPages) {
|
||||
_pages.RemoveAt(_pages.Count - 1);
|
||||
MaxPage--;
|
||||
}
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all pages from the buffer.
|
||||
/// </summary>
|
||||
public void Clear() {
|
||||
_pages.Clear();
|
||||
MinPage = 0;
|
||||
MaxPage = 0;
|
||||
FlatList = [];
|
||||
}
|
||||
|
||||
void Rebuild() {
|
||||
var seen = new Dictionary<Guid, T>();
|
||||
foreach (var page in _pages)
|
||||
foreach (var item in page)
|
||||
if (!seen.ContainsKey(_keySelector(item)))
|
||||
seen[_keySelector(item)] = item;
|
||||
FlatList = [.. seen.Values];
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,9 @@ public sealed class AlbumService(
|
||||
/// <param name="sortBy">Optional field to sort by (e.g. "name", "created", "updated", "assets", "person").</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>
|
||||
/// <param name="personOwnerId">Optional person ID to filter albums by their owner.</param>
|
||||
/// <returns>A list of album previews, or null if the request failed.</returns>
|
||||
public async Task<List<AlbumPreviewDto>?> GetAlbumsAsync(int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? search = null, string? sortBy = null, bool sortAsc = false, bool unassigned = false) {
|
||||
public async Task<List<AlbumPreviewDto>?> GetAlbumsAsync(int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? search = null, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid? personOwnerId = null) {
|
||||
var url = $"/api/album?page={page}&pageSize={pageSize}";
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
url += $"&search={Uri.EscapeDataString(search)}";
|
||||
@@ -34,6 +35,8 @@ public sealed class AlbumService(
|
||||
url += $"&sortAsc={sortAsc.ToString().ToLowerInvariant()}";
|
||||
if (unassigned)
|
||||
url += "&unassigned=true";
|
||||
if (personOwnerId.HasValue)
|
||||
url += $"&personOwnerId={personOwnerId.Value}";
|
||||
|
||||
var response = await Client.GetAsync(url);
|
||||
|
||||
|
||||
@@ -26,10 +26,11 @@ public sealed class AssetService(
|
||||
/// <param name="folderId">Optional folder ID to filter assets by their scan folder.</param>
|
||||
/// <param name="uploadedBy">Optional uploader user ID to filter assets by their uploader.</param>
|
||||
/// <param name="search">Optional search term for filename matching.</param>
|
||||
/// <param name="includeCount">If true, the API also computes the total matching count. Skipping it avoids a full count query.</param>
|
||||
/// <returns>A list of asset previews, or null if the request failed.</returns>
|
||||
public async Task<List<AssetPreviewDto>?> GetAssetsAsync(
|
||||
EAssetType? type = null, bool random = false, Guid? seed = null, int page = 0, int pageSize = PagedParametersDto.MaxPageSize,
|
||||
bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null
|
||||
bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true
|
||||
) {
|
||||
var url = $"/api/asset?page={page}&pageSize={pageSize}&random={random}";
|
||||
if (type.HasValue)
|
||||
@@ -44,6 +45,8 @@ public sealed class AssetService(
|
||||
url += $"&uploadedBy={uploadedBy.Value}";
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
url += $"&search={Uri.EscapeDataString(search)}";
|
||||
if (!includeCount)
|
||||
url += "&includeCount=false";
|
||||
|
||||
var response = await Client.GetAsync(url);
|
||||
|
||||
|
||||
@@ -12,15 +12,6 @@ public sealed class FoldersService(
|
||||
LoginService loginService,
|
||||
ILogger<FoldersService> logger
|
||||
) : AuthServiceBase(options, httpClientFactory, loginService, logger) {
|
||||
/// <summary>
|
||||
/// Fired when folders are saved.
|
||||
/// </summary>
|
||||
public event EventHandler? SaveFolders;
|
||||
/// <summary>
|
||||
/// Invokes the <see cref="SaveFolders"/> event.
|
||||
/// </summary>
|
||||
public void OnSaveFolders() => SaveFolders?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tracked folders.
|
||||
/// </summary>
|
||||
@@ -33,19 +24,6 @@ public sealed class FoldersService(
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a folder by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <returns>The folder details, or null if not found.</returns>
|
||||
public async Task<FolderFullDto?> GetFolderById(Guid id) {
|
||||
var response = await Client.GetAsync($"/api/folder/{id}");
|
||||
|
||||
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<FolderFullDto>();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new folder.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace MilkStream.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Static formatting helpers used across the client.
|
||||
/// </summary>
|
||||
public static class Formatting {
|
||||
/// <summary>
|
||||
/// Formats a byte count into a human-readable size string (B/KB/MB/GB).
|
||||
/// </summary>
|
||||
/// <param name="bytes">The byte count to format.</param>
|
||||
/// <returns>A human-readable size string.</returns>
|
||||
public static string FormatBytes(long bytes) => bytes switch {
|
||||
>= 1_073_741_824 => $"{bytes / 1_073_741_824.0:F2} GB",
|
||||
>= 1_048_576 => $"{bytes / 1_048_576.0:F2} MB",
|
||||
>= 1_024 => $"{bytes / 1_024.0:F2} KB",
|
||||
_ => $"{bytes} B"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Formats a count as a percentage of a total.
|
||||
/// </summary>
|
||||
/// <param name="count">The partial count.</param>
|
||||
/// <param name="total">The total to divide by.</param>
|
||||
/// <returns>A percentage string with one decimal place, or "0%" when the total is not positive.</returns>
|
||||
public static string PercentOfTotal(int count, int total) =>
|
||||
total > 0 ? $"{count / (double)total * 100:F1}%" : "0%";
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net;
|
||||
using Blazored.LocalStorage;
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.User;
|
||||
@@ -130,8 +131,11 @@ public sealed class LoginService : ServiceBase {
|
||||
AuthInfo? auth = null;
|
||||
try {
|
||||
auth = await localStorage.GetItemAsync<AuthInfo>("auth");
|
||||
} catch {
|
||||
try { await localStorage.RemoveItemAsync("auth"); } catch { }
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Failed to read auth from local storage, attempting cleanup");
|
||||
try { await localStorage.RemoveItemAsync("auth"); } catch (Exception cleanupEx) {
|
||||
logger.LogWarning(cleanupEx, "Failed to cleanup auth from local storage");
|
||||
}
|
||||
}
|
||||
|
||||
if (auth == null)
|
||||
@@ -190,7 +194,9 @@ public sealed class LoginService : ServiceBase {
|
||||
var failureResult = await response.Content.ReadFromJsonAsync<AuthResultDto>();
|
||||
if (!string.IsNullOrEmpty(failureResult?.ErrorMessage))
|
||||
error = failureResult.ErrorMessage;
|
||||
} catch { /* use default message */ }
|
||||
} catch (Exception ex) {
|
||||
logger.LogDebug(ex, "Failed to parse login failure response, using default message");
|
||||
}
|
||||
|
||||
return (false, error, null);
|
||||
}
|
||||
@@ -252,7 +258,9 @@ public sealed class LoginService : ServiceBase {
|
||||
ForceLogout?.Invoke(error);
|
||||
return;
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Failed to parse refresh failure response");
|
||||
}
|
||||
|
||||
ForceLogoutReason = "Session expired. Please log in again.";
|
||||
await Logout();
|
||||
@@ -265,8 +273,8 @@ public sealed class LoginService : ServiceBase {
|
||||
/// <param name="username">The desired username.</param>
|
||||
/// <param name="email">The email address.</param>
|
||||
/// <param name="password">The password.</param>
|
||||
/// <returns>True if registration succeeded.</returns>
|
||||
public async Task<bool> Register(string username, string email, string password) {
|
||||
/// <returns>HttpStatusCode indicating the result (200 = success, 403 = disabled, 409 = conflict).</returns>
|
||||
public async Task<HttpStatusCode> Register(string username, string email, string password) {
|
||||
logger.LogInformation("Attempting to register user with username: {Username}", username);
|
||||
|
||||
var registerDto = new UserRegisterDto() {
|
||||
@@ -277,7 +285,7 @@ public sealed class LoginService : ServiceBase {
|
||||
|
||||
var response = await Client.PostAsJsonAsync("api/auth/register", registerDto);
|
||||
logger.LogInformation("Result: {ResponseStatusCode}", response.StatusCode);
|
||||
return response.IsSuccessStatusCode;
|
||||
return response.StatusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -287,12 +295,16 @@ public sealed class LoginService : ServiceBase {
|
||||
logger.LogInformation("Logging out current user with ID: {AuthInfoUserId}", authInfo?.UserId);
|
||||
try {
|
||||
await Client.PostAsync("api/auth/logout", null);
|
||||
} catch { /* best-effort — clear local state regardless */ }
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Logout API call failed, clearing local state anyway");
|
||||
}
|
||||
AuthInfo = null;
|
||||
LoggedUser = null;
|
||||
try {
|
||||
await localStorage.RemoveItemAsync("auth");
|
||||
} catch { }
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Failed to clear auth from local storage");
|
||||
}
|
||||
logger.LogInformation("Logout completed");
|
||||
}
|
||||
|
||||
@@ -321,5 +333,18 @@ public sealed class LoginService : ServiceBase {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether user registration is currently enabled on the server.
|
||||
/// </summary>
|
||||
/// <returns>True if registration is allowed.</returns>
|
||||
public async Task<bool> IsRegistrationEnabledAsync() {
|
||||
try {
|
||||
var response = await Client.GetAsync("api/auth/register");
|
||||
return response.StatusCode == HttpStatusCode.OK;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user