Compare commits
195
Commits
108b53a7e2
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0b67b0404 | ||
|
|
fb2b02e71e | ||
|
|
4658ed1a27 | ||
|
|
52c08089b2 | ||
|
|
111875670b | ||
|
|
9c50f84783 | ||
|
|
593d88d3e3 | ||
|
|
abef50b010 | ||
|
|
ce221428f5 | ||
|
|
213c643263 | ||
|
|
ecc6e02c78 | ||
|
|
b78c5772b4 | ||
|
|
6463df51d1 | ||
|
|
dd2d703fc0 | ||
|
|
a388887f56 | ||
|
|
7d30ea2394 | ||
|
|
ab420fe167 | ||
|
|
19e177504f | ||
|
|
f57e2864c5 | ||
|
|
d89f6f0a21 | ||
|
|
ca217ddb59 | ||
|
|
e025866357 | ||
|
|
1449664678 | ||
|
|
cf8bee555a | ||
|
|
664ac54a7c | ||
|
|
df48dd17f4 | ||
|
|
f15fe8f31a | ||
|
|
8589a85625 | ||
|
|
983cd8baef | ||
|
|
f52b376fca | ||
|
|
75e57353e7 | ||
|
|
eae6152738 | ||
|
|
c4788e1572 | ||
|
|
1dfa9eaf41 | ||
|
|
50b5a28841 | ||
|
|
580cdee67d | ||
|
|
8f0267b0f7 | ||
|
|
5d9aa21987 | ||
|
|
ad1f9e2222 | ||
|
|
90c81c2d20 | ||
|
|
0cc80a084a | ||
|
|
ad7c38b20e | ||
|
|
e0a5bd0ce4 | ||
|
|
e41e7d774f | ||
|
|
34220e93d4 | ||
|
|
22706a09cd | ||
|
|
664c1de5da | ||
|
|
081295af86 | ||
|
|
7dd00c0a5e | ||
|
|
3a195dbc15 | ||
|
|
cce310ee14 | ||
|
|
ada113914d | ||
|
|
a2a25673de | ||
|
|
963e897fe5 | ||
|
|
f9fe91e975 | ||
|
|
01fc121bc3 | ||
|
|
da8a5ad539 | ||
|
|
ff2ed4f7cd | ||
|
|
2c292d2d81 | ||
|
|
f064277c31 | ||
|
|
c5ea749517 | ||
|
|
ebe83fe691 | ||
|
|
ab58ac7dd8 | ||
|
|
17d3f0f2a7 | ||
|
|
1e2bc83b20 | ||
|
|
58014a37bd | ||
|
|
c79ed47e41 | ||
|
|
e48da562fa | ||
|
|
b943d2e595 | ||
|
|
0950106609 | ||
|
|
7ba1ab1551 | ||
|
|
656e6fa9bf | ||
|
|
5e1306e466 | ||
|
|
22565c4f92 | ||
|
|
e62a567e4e | ||
|
|
e5ffe86077 | ||
|
|
d4a53cb2dc | ||
|
|
548b0d5bfd | ||
|
|
92fb5a0149 | ||
|
|
6542dcafd6 | ||
|
|
b7690b5828 | ||
|
|
f3f2bdba02 | ||
|
|
8bf6405c64 | ||
|
|
c1b1feef4d | ||
|
|
0e9820f7c3 | ||
|
|
133c697176 | ||
|
|
8f07d7c445 | ||
|
|
c7e83ed97b | ||
|
|
7db1738678 | ||
|
|
3e099987a3 | ||
|
|
ca50554108 | ||
|
|
2bfa370989 | ||
|
|
dbd5d15fa1 | ||
|
|
b1f32844a9 | ||
|
|
3bf98cbfed | ||
|
|
80c9c8bd6f | ||
|
|
7086fb127f | ||
|
|
19ab8c0975 | ||
|
|
d7f8350ae7 | ||
|
|
f0e3cc9c37 | ||
|
|
4f56af252b | ||
|
|
8aeb4df064 | ||
|
|
dc2983ae55 | ||
|
|
a50dc3aa30 | ||
|
|
3cdad1cebd | ||
|
|
0903da4490 | ||
|
|
2fcd6467e1 | ||
|
|
10337c735c | ||
|
|
e3d4cf9673 | ||
|
|
a203885377 | ||
|
|
fff5ea65df | ||
|
|
86f95d7461 | ||
|
|
ec287f087c | ||
|
|
758696f7c9 | ||
|
|
de9cd618b6 | ||
|
|
e7fb01be9f | ||
|
|
e4a34a7dad | ||
|
|
d799c3eb1f | ||
|
|
c5fabf0e07 | ||
|
|
3cdf9eb185 | ||
|
|
6efccae030 | ||
|
|
93cbbea3c7 | ||
|
|
cace8640e9 | ||
|
|
a3ce98a984 | ||
|
|
4f3e3a28cc | ||
|
|
657ffaae11 | ||
|
|
700d5a63a7 | ||
|
|
9531ac2e23 | ||
|
|
288da412d9 | ||
|
|
1b34520a66 | ||
|
|
23b285cf49 | ||
|
|
27707b120c | ||
|
|
14978087e3 | ||
|
|
3021788271 | ||
|
|
358bf0918e | ||
|
|
656784af00 | ||
|
|
c8b71f290b | ||
|
|
8f109bf92f | ||
|
|
268b380867 | ||
|
|
71889ddfac | ||
|
|
0a74ff8770 | ||
|
|
f158181ccf | ||
|
|
e585abc378 | ||
|
|
ccf16b6abc | ||
|
|
7dcb78db97 | ||
|
|
26984f4452 | ||
|
|
f32d0678df | ||
|
|
05b99ab6c5 | ||
|
|
88a0098314 | ||
|
|
8b53899648 | ||
|
|
e2b0eaba37 | ||
|
|
e1504dbf72 | ||
|
|
b67b2260fe | ||
|
|
06fd26e641 | ||
|
|
74c74cbbdf | ||
|
|
774ee0d7c7 | ||
|
|
45b3c56ce2 | ||
|
|
b0fa104642 | ||
|
|
d4cd305c28 | ||
|
|
671783fd16 | ||
|
|
674710986f | ||
|
|
77a6ee9747 | ||
|
|
1f2bf02d02 | ||
|
|
c5b8c93a39 | ||
|
|
b9251c3e19 | ||
|
|
0d1ce9feb3 | ||
|
|
0673017765 | ||
|
|
08c389026a | ||
|
|
bbb2ed8f73 | ||
|
|
fe33fe2044 | ||
|
|
a449c792c1 | ||
|
|
9323111d0d | ||
|
|
c2326414b4 | ||
|
|
747ad77f4c | ||
|
|
f77a4574ac | ||
|
|
c64c1947e6 | ||
|
|
1b444dab93 | ||
|
|
50bf0a2a78 | ||
|
|
2335e8e23a | ||
|
|
d02a010083 | ||
|
|
6676347353 | ||
|
|
c9391d840b | ||
|
|
51323b4a64 | ||
|
|
842ed76345 | ||
|
|
f3ed658dd0 | ||
|
|
6c83b8395b | ||
|
|
e58524ce19 | ||
|
|
9dced21554 | ||
|
|
3fdf55b53c | ||
|
|
f5b28e5a21 | ||
|
|
7a380d7df5 | ||
|
|
f7d6348ea9 | ||
|
|
46871299ca | ||
|
|
c281746fdc | ||
|
|
be7532dd74 |
@@ -0,0 +1,67 @@
|
||||
name: Build and Push Containers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- hotfix/*
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v1.2.3)'
|
||||
required: true
|
||||
default: 'manual'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
strategy:
|
||||
matrix:
|
||||
service: [Lactose, MilkStream]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.r3d.codes
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Determine version tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "VERSION=$(echo '${{ github.event.inputs.version }}' | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
else
|
||||
echo "VERSION=$(echo '${{ gitea.ref_name }}' | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Lowercase repository name
|
||||
run: echo "REPO_LC=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Lowercase service name
|
||||
run: echo "SERVICE_LC=$(echo '${{ matrix.service }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare build args for ${{ matrix.service }}
|
||||
run: |
|
||||
if [ "${{ matrix.service }}" = "Lactose" ]; then
|
||||
echo "BUILD_ARGS=SixLaborsLicenseKey=${{ secrets.SIXLABORS_KEY }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BUILD_ARGS=" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build and push ${{ matrix.service }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./${{ matrix.service }}/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
git.r3d.codes/${{ env.REPO_LC }}/${{ env.SERVICE_LC }}:${{ env.VERSION }}
|
||||
git.r3d.codes/${{ env.REPO_LC }}/${{ env.SERVICE_LC }}:latest
|
||||
build-args: ${{ env.BUILD_ARGS }}
|
||||
@@ -8,3 +8,5 @@ storageImages/
|
||||
dotnet-tools.json
|
||||
.opencode/
|
||||
*.css.map
|
||||
# SixLabors license file
|
||||
/Lactose/sixlabors.lic
|
||||
@@ -7,7 +7,7 @@
|
||||
- **.NET 10 SDK** (pinned in `global.json`; `allowPrerelease: true`)
|
||||
- **PostgreSQL with pgvector extension**
|
||||
- **SCSS** compiled by `AspNetCore.SassCompiler` MSBuild task (no Node.js needed; compiles during `dotnet build`)
|
||||
- Run `dotnet tool restore` before first migration — installs `dotnet-ef`
|
||||
- Run `dotnet tool restore` before first migration — installs `dotnet-ef` (tool manifest at `dotnet-tools.json` in repo root)
|
||||
|
||||
## SCSS
|
||||
|
||||
@@ -48,14 +48,12 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
||||
|
||||
## Infrastructure gotchas
|
||||
|
||||
- **Npgsql legacy timestamps:** `AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true)` is set in `Lactose/Program.cs`. Do not remove without understanding the timestamp implications.
|
||||
- **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.
|
||||
- **Auth requires claims transformation:** `RefreshTokenTransformation` (registered as `IClaimsTransformation`) must succeed on every authenticated request, or all endpoints return 403.
|
||||
- **EF Core Tools v8.0.15 vs EF Core Design v10.0.9:** Version mismatch may cause `dotnet ef` CLI quirks.
|
||||
- **`dotnet-ef` CLI vs Microsoft.EntityFrameworkCore.Tools:** The global `dotnet-ef` (from `dotnet-tools.json`, v10.0.9) is the correct CLI to use. The Tools package reference in `Lactose.csproj` (v10.0.9) provides the `dotnet ef` target for `dotnet build`-time validation.
|
||||
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (default) for login, and one named `"MilkstreamClient"` which has `JwtTokenRefresher` as a `DelegatingHandler`. The handler transparently refreshes expired tokens pre-flight and retries once on 401 — authenticated services must **never** handle token refreshes manually. Do not add `SendWithRefreshAsync` or similar wrappers; rely solely on the named client.
|
||||
- **HTTPS redirection is commented out** in Lactose — API does not enforce HTTPS.
|
||||
- **`SharedWith.Any()` EF Core translation:** Cannot be translated to SQL through many-to-many join tables (`AlbumAsset`, `AssetUser`). When visibility filtering includes `SharedWith.Any(u => u.Id == userId)`, you must materialize records first with `.Include().ThenInclude()` chains, then filter client-side in C#. The two-pass pagination approach preserves page-limited loading — see `PersonRepository.SearchQuery` and `AlbumRepository.SearchQuery` for the pattern.
|
||||
- **DateTime analyzer:** `Lactose.Analyzers` treats `DateTime.Now` as error MS001. Always use `DateTime.UtcNow`. All timestamp columns use `timestamp with time zone`.
|
||||
- **DateTime analyzer:** `Lactose.Analyzers` treats `DateTime.Now` as error MS001, `DateTime.Today` as error MS002, `DateTime.Date` as warning MS003, and non-UTC `SpecifyKind` as error MS004. Always use `DateTime.UtcNow`. All timestamp columns use `timestamp with time zone`.
|
||||
|
||||
## XML docs enforced
|
||||
|
||||
@@ -73,7 +71,49 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
||||
- **Granular commits** — commit each logical change separately (e.g., DTO change, controller logic, UI component) with a descriptive commit message
|
||||
- **Reuse API endpoints** — prefer adding optional query parameters to existing endpoints over creating new routes. New endpoints are a last resort when the existing ones fundamentally cannot serve the need.
|
||||
|
||||
## Modularity
|
||||
## Visibility Rules (R1–R7)
|
||||
|
||||
Defined in issue [#129](https://git.r3d.codes/MilkyShots/MilkyShots/issues/129). Each entity (Asset, Album, Person) carries **independent** `EVisibility` — no inheritance from container to child.
|
||||
|
||||
### R1 — Independent Visibility
|
||||
No entity's visibility is overridden by its container. An asset in a Public album does NOT become Public; the asset is only visible if the viewer has permission for the asset's own visibility.
|
||||
|
||||
### R2 — Asset Visibility (Per-Asset Gate)
|
||||
|
||||
| Actor | Public | Protected | Private |
|
||||
|---|---|---|---|
|
||||
| Anonymous | ✅ | ❌ | ❌ |
|
||||
| User | ✅ | ✅ | ❌ |
|
||||
| Maintainer (not owning / not linked) | ✅ | ✅ | ❌ |
|
||||
| Maintainer (uploaded by self) | ✅ | ✅ | ✅ |
|
||||
| Maintainer (album → person they maintain) | ✅ | ✅ | ✅ |
|
||||
| Curator (non-deleted) | ✅ | ✅ | ✅ |
|
||||
| Curator (deleted, own upload) | ✅ | ✅ | ✅ |
|
||||
| Admin (all, including deleted) | ✅ | ✅ | ✅ |
|
||||
|
||||
### R3 — Album Access Gating
|
||||
Album's own visibility determines who can access it. Not visible → 404.
|
||||
|
||||
| Actor | Public | Protected | Private |
|
||||
|---|---|---|---|
|
||||
| Anonymous / User | ✅ | ✅ | ❌ |
|
||||
| Maintainer (unassigned) | ✅ | ✅ | ❌ |
|
||||
| Maintainer (maintains PersonOwner) | ✅ | ✅ | ✅ |
|
||||
| Curator / Admin | ✅ | ✅ | ✅ |
|
||||
|
||||
### R4 — Person Access Gating
|
||||
Same as R3. Not visible → 404.
|
||||
|
||||
### R5 — Media Serving
|
||||
Uses per-asset rules (R2). Album provenance not checked — too complex, marginal benefit.
|
||||
|
||||
### R6 — Edit Mode (Frontend)
|
||||
API returns Private assets to privileged users (Admin/Curator/Maintainer-on-owned-content). Frontend hides them in browse mode, shows them in edit/curator mode. No backend flag.
|
||||
|
||||
### R7 — Deleted Assets
|
||||
- **Admin**: all deleted assets visible
|
||||
- **Curator**: own deleted uploads visible
|
||||
- **Everyone else**: deleted assets hidden (`DeletedAt IS NULL`)
|
||||
|
||||
Reusable UI components live in `MilkStream.Client/Components/Shared/`.
|
||||
When a markup pattern appears in 2+ places, extract it into a shared component.
|
||||
@@ -94,10 +134,10 @@ dotnet ef migrations add <Name> --project Lactose
|
||||
dotnet ef database update --project Lactose
|
||||
```
|
||||
|
||||
**Database indexes for performance** should be defined using the **fluent API** in `LactoseDbContext.OnModelCreating` via `HasIndex().HasFilter()`. Do not use the `[Index]` data annotation — the `Filter` parameter is not available in the current package configuration (EF Core Tools v8.0.15 vs Design v10.0.9 mismatch). Example:
|
||||
**Database indexes for performance** should be defined using the **fluent API** in `LactoseDbContext.OnModelCreating` via `HasIndex().HasFilter()`. Do not use the `[Index]` data annotation — the `Filter` parameter is not available in the current package configuration (EF Core Tools v10.0.9 vs Design v10.0.9 mismatch). Example:
|
||||
|
||||
```csharp
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.IsPubliclyShared, e.OwnerId })
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
```
|
||||
@@ -109,10 +149,41 @@ modelBuilder.Entity<Asset>().HasIndex(e => new { e.IsPubliclyShared, e.OwnerId }
|
||||
|
||||
No unit/integration test project exists. API endpoint testing is done via **REST Client `.http` files**:
|
||||
|
||||
- **`Lactose/WepApiTest.http`** — 67 numbered tests covering all API endpoints across 10 sections (Auth → User CRUD → Person → Album → Tag → Asset → Stats → Settings → Cleanup). Tests verify authorization at every level (anonymous, user, curator, admin). Self-contained — all test data is created at runtime and cleaned up.
|
||||
- **`Lactose/WepApiTest.http`** — 100+ numbered tests covering all API endpoints across 12 sections (Auth → User CRUD → Person → Album → Tag → Asset → Stats → Settings → Data Visibility → Visibility Gates → Cleanup). Tests verify authorization at every level (anonymous, user, curator, admin) and include comprehensive visibility gate tests (R2–R4). Self-contained — all test data is created at runtime and cleaned up.
|
||||
- **`Lactose/http-client.env.json`** — provides pre-filled variable values (`userName`, `curatorName`, etc.) for the REST Client extension.
|
||||
- Open in VS Code/Rider with the REST Client extension and run individual tests or the full suite.
|
||||
- Server must be running locally (default `http://localhost:5162`).
|
||||
- Paginated list tests (people, albums, users) use `?page=0&pageSize=5` — change these carefully.
|
||||
|
||||
## PR workflow
|
||||
|
||||
- Before starting work, ask the user whether they want the fix in a PR or directly on `develop` (for quick/trivial fixes).
|
||||
- Larger work always goes in a PR.
|
||||
- Always mark the PR as **draft** until the user confirms they are satisfied and have tested it works.
|
||||
- When making the PR ready (removing draft status), mark it ready for review and request:
|
||||
- **REDCODE** when the PR contains frontend changes
|
||||
- **Fastwind** when the PR contains backend changes
|
||||
- **Both** when both areas are touched
|
||||
|
||||
## Data sizes
|
||||
|
||||
Approximate record counts in the database:
|
||||
|
||||
| Entity | Count |
|
||||
|---|---|
|
||||
| Assets | 1,000,000+ |
|
||||
| Albums | 10,000+ |
|
||||
| People | 1,000+ |
|
||||
| Users | 100+ |
|
||||
| Maintainers | 10+ |
|
||||
| Curators | single digit |
|
||||
| Admins | single digit |
|
||||
|
||||
## Gitea labels
|
||||
|
||||
Labels live at two levels:
|
||||
- **Org level** (`MilkyShots` org): generic issue types (`bug`, `enhancement`, `help wanted`, `question`, `duplicate`, `invalid`, `wontfix`)
|
||||
- **Repo level** (`MilkyShots/MilkyShots`): project-specific labels (`area:frontend`, `area:backend`, `page:*`, `type:refactor`, `priority:*`, etc.)
|
||||
- When labelling issues, check both levels with the `label_read` tool (`list_org_labels` / `list_repo_labels`).
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,25 +9,33 @@ public class AlbumAssetPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a thumbnail.
|
||||
/// </summary>
|
||||
public bool HasThumbnail { get; set; }
|
||||
public bool HasThumbnail { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a preview.
|
||||
/// </summary>
|
||||
public bool HasPreview { get; set; }
|
||||
public bool HasPreview { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the original filename of the asset.
|
||||
/// </summary>
|
||||
public string? FileName { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the soft-delete timestamp. Only populated for admins and the asset uploader.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,21 +9,21 @@ public class AlbumCreateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the album.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the album.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID associated with the album.
|
||||
/// </summary>
|
||||
public Guid? Person { get; set; }
|
||||
public Guid? Person { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover asset ID for the album.
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of asset IDs in the album.
|
||||
/// </summary>
|
||||
public List<Guid>? Assets { get; set; }
|
||||
public List<Guid>? Assets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||
/// </summary>
|
||||
public EVisibility Visibility { get; set; } = EVisibility.Protected;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to merge multiple albums into a single destination album.
|
||||
/// </summary>
|
||||
public class AlbumMergeDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the destination album that will receive all assets.
|
||||
/// Must not be present in <see cref="SourceIds"/>.
|
||||
/// </summary>
|
||||
public required Guid DestinationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of source album IDs whose assets will be moved to the destination.
|
||||
/// Source albums will be hard-deleted after the merge.
|
||||
/// </summary>
|
||||
public required List<Guid> SourceIds { get; set; } = [];
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,29 +9,33 @@ public class AlbumPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the album.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the album.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the album.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID associated with the album.
|
||||
/// </summary>
|
||||
public Guid? Person { get; set; }
|
||||
public Guid? Person { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the person name associated with the album.
|
||||
/// </summary>
|
||||
public string? PersonName { get; set; }
|
||||
public string? PersonName { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the cover asset for this album.
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets in the album.
|
||||
/// </summary>
|
||||
public int AssetCount { get; set; }
|
||||
public int AssetCount { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the soft-delete timestamp. Always null for albums (not supported).
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -8,4 +8,10 @@ public class AlbumSearchParametersDto : PagedSearchParametersDto {
|
||||
/// Gets or sets whether to return only albums with no person assigned.
|
||||
/// </summary>
|
||||
public bool Unassigned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user ID to filter albums by.
|
||||
/// Only returns albums that contain at least one asset uploaded by the specified user.
|
||||
/// </summary>
|
||||
public Guid? AssetUploadedBy { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,25 +9,25 @@ public class AlbumUpdateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the album.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the album.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
public string? Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID associated with the album.
|
||||
/// </summary>
|
||||
public Guid? Person { get; set; }
|
||||
public Guid? Person { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to remove the person assignment from the album.
|
||||
/// </summary>
|
||||
public bool RemovePerson { get; set; }
|
||||
public bool RemovePerson { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover asset ID for the album.
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of asset IDs in the album.
|
||||
/// </summary>
|
||||
public List<Guid>? Assets { get; set; }
|
||||
public List<Guid>? Assets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the parameters for browsing assets within a folder at a specific directory level.
|
||||
/// </summary>
|
||||
public class AssetBrowseOptionsDto : PagedSearchParametersDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the root folder ID to browse within.
|
||||
/// </summary>
|
||||
public Guid FolderId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the current path relative to the folder root (empty for root).
|
||||
/// </summary>
|
||||
public string Path { get; set; } = "";
|
||||
/// <summary>
|
||||
/// Gets or sets whether to only include assets not assigned to any album. Defaults to true.
|
||||
/// </summary>
|
||||
public bool UnlinkedOnly { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of browsing unlinked assets at a specific directory level within a folder.
|
||||
/// </summary>
|
||||
public class AssetBrowseResultDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the list of immediate subdirectories at the current path level.
|
||||
/// </summary>
|
||||
public List<DirectoryEntryDto> Directories { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Gets or sets the list of assets located directly at the current path level.
|
||||
/// </summary>
|
||||
public List<AssetPreviewDto> Assets { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Gets or sets the current browsing path relative to the folder root.
|
||||
/// </summary>
|
||||
public string CurrentPath { get; set; } = "";
|
||||
/// <summary>
|
||||
/// Gets or sets the total count of assets at the current path level (for pagination).
|
||||
/// </summary>
|
||||
public int TotalAssetCount { get; set; }
|
||||
}
|
||||
@@ -9,41 +9,42 @@ public class AssetCreateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the creation date of the asset.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the asset.
|
||||
/// Gets or sets the ID of the user who uploaded the asset.
|
||||
/// Null for scanner-created assets (system uploader will be used).
|
||||
/// </summary>
|
||||
public Guid Owner { get; set; }
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the asset.
|
||||
/// </summary>
|
||||
public EAssetType AssetType { get; set; }
|
||||
public EAssetType AssetType { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset is publicly shared.
|
||||
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||
/// </summary>
|
||||
public bool IsPublic { get; set; }
|
||||
public EVisibility Visibility { get; set; } = EVisibility.Private;
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the file size in bytes.
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the duration in seconds (for video/audio).
|
||||
/// </summary>
|
||||
public int? Duration { get; set; }
|
||||
public int? Duration { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the frame rate (for video).
|
||||
/// </summary>
|
||||
public int? FrameRate { get; set; }
|
||||
public int? FrameRate { get; set; }
|
||||
}
|
||||
|
||||
@@ -10,41 +10,33 @@ public class AssetDto : AssetPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the asset (e.g., Image, Video).
|
||||
/// </summary>
|
||||
public EAssetType AssetType { get; set; }
|
||||
public EAssetType AssetType { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset is publicly shared.
|
||||
/// Gets or sets the user who uploaded the asset.
|
||||
/// </summary>
|
||||
public bool IsPublic { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the owner of the asset.
|
||||
/// </summary>
|
||||
public required UserInfoDto Owner { get; set; }
|
||||
public required UserInfoDto UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the creation date of the asset.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the last updated date of the asset.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the deletion date of the asset.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
public new string MimeType { get; set; } = string.Empty;
|
||||
public new string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the file size of the asset in bytes.
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the duration of the asset in seconds (for video/audio).
|
||||
/// </summary>
|
||||
public float Duration { get; set; }
|
||||
public float Duration { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the frame rate of the asset (for video).
|
||||
/// </summary>
|
||||
public float FrameRate { get; set; }
|
||||
public float FrameRate { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a group of unlinked assets (not assigned to any album),
|
||||
/// grouped by folder for drill-down browsing in the asset picker.
|
||||
/// </summary>
|
||||
public class AssetGroupDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the folder ID of the group. Null for assets with no folder or for the maintainer's "My Uploads" group.
|
||||
/// </summary>
|
||||
public Guid? FolderId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the display name of the group (folder base path, or "My Uploads" for maintainers).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the count of unlinked assets in this group.
|
||||
/// </summary>
|
||||
public int AssetCount { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,45 +9,53 @@ public class AssetPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the asset.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a generated thumbnail.
|
||||
/// </summary>
|
||||
public bool HasThumbnail { get; set; }
|
||||
public bool HasThumbnail { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a generated preview.
|
||||
/// </summary>
|
||||
public bool HasPreview { get; set; }
|
||||
public bool HasPreview { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of album titles associated with the asset.
|
||||
/// </summary>
|
||||
public List<string>? AlbumNames { get; set; }
|
||||
public List<string>? AlbumNames { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of album IDs associated with the asset, parallel to <see cref="AlbumNames"/>.
|
||||
/// </summary>
|
||||
public List<Guid>? AlbumIds { get; set; }
|
||||
public List<Guid>? AlbumIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of cosplayer names associated with the asset, derived from album person owners.
|
||||
/// </summary>
|
||||
public List<string>? CosplayerNames { get; set; }
|
||||
public List<string>? CosplayerNames { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of cosplayer IDs associated with the asset, parallel to <see cref="CosplayerNames"/>.
|
||||
/// </summary>
|
||||
public List<Guid>? CosplayerIds { get; set; }
|
||||
public List<Guid>? CosplayerIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the original filename of the asset.
|
||||
/// </summary>
|
||||
public string? FileName { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the soft-delete timestamp. Only populated for admins and the asset uploader.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -26,4 +26,16 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
|
||||
/// Gets or sets whether to order results randomly instead of by date.
|
||||
/// </summary>
|
||||
public bool Random { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to filter for assets not assigned to any album (unlinked).
|
||||
/// </summary>
|
||||
public bool Unlinked { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the folder ID to filter assets by their scan folder.
|
||||
/// </summary>
|
||||
public Guid? FolderId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the uploader user ID to filter assets by their uploader.
|
||||
/// </summary>
|
||||
public Guid? UploadedBy { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
@@ -5,15 +7,15 @@ namespace Butter.Dtos.Asset;
|
||||
/// </summary>
|
||||
public class AssetUpdateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the new owner ID for the asset.
|
||||
/// Gets or sets the ID of the user who uploaded the asset.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the deletion date to set on the asset.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset is publicly shared.
|
||||
/// Gets or sets the visibility level.
|
||||
/// </summary>
|
||||
public bool? IsPublic { get; set; }
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a subdirectory entry in the asset file browser.
|
||||
/// </summary>
|
||||
public class DirectoryEntryDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the directory name (a single path segment, not a full path).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,21 +9,25 @@ public class PersonCreateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the person.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID to use as the profile picture.
|
||||
/// </summary>
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||
/// </summary>
|
||||
public EVisibility Visibility { get; set; } = EVisibility.Protected;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
@@ -39,7 +40,19 @@ public class PersonDetailedDto {
|
||||
/// </summary>
|
||||
public int TotalAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of album previews associated with this person.
|
||||
/// </summary>
|
||||
public List<AlbumPreviewDto>? Albums { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the IDs of users who maintain this person.
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainerUserIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the usernames of users who maintain this person.
|
||||
/// </summary>
|
||||
public List<string>? MaintainerUsernames { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to merge multiple people into a single destination person.
|
||||
/// </summary>
|
||||
public class PersonMergeDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the destination person that will receive all associated data.
|
||||
/// Must not be present in <see cref="SourceIds"/>.
|
||||
/// </summary>
|
||||
public required Guid DestinationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of source person IDs whose albums, faces, and maintainers
|
||||
/// will be reassigned to the destination. Source people will be hard-deleted.
|
||||
/// </summary>
|
||||
public required List<Guid> SourceIds { get; set; } = [];
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,29 +9,33 @@ public class PersonPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the person.
|
||||
/// </summary>
|
||||
public required Guid Id { get; set; }
|
||||
public required Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the person.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID used as the profile picture.
|
||||
/// </summary>
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of albums associated with this person.
|
||||
/// </summary>
|
||||
public int TotalAlbums { get; set; }
|
||||
public int TotalAlbums { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,21 +9,29 @@ public class PersonUpdateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the person.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID to use as the profile picture.
|
||||
/// </summary>
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of user IDs that should be maintainers of this person (admin/curator only).
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainerUserIds { get; set; }
|
||||
}
|
||||
|
||||
@@ -75,7 +75,11 @@ public class StatsDto {
|
||||
/// </summary>
|
||||
public int PublicAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of non-publicly-shared assets.
|
||||
/// Gets or sets the number of assets with protected visibility (authenticated only).
|
||||
/// </summary>
|
||||
public int ProtectedAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of private assets.
|
||||
/// </summary>
|
||||
public int PrivateAssets { get; set; }
|
||||
|
||||
@@ -116,10 +120,9 @@ public class StatsDto {
|
||||
/// </summary>
|
||||
public int AlbumsMissingCover { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of people/cosplayers without an album that has a cover image.
|
||||
/// Gets or sets the number of people/cosplayers without a profile picture.
|
||||
/// </summary>
|
||||
/// <remarks>Not wired yet — reserved for future use.</remarks>
|
||||
public int CosplayersMissingCover { get; set; }
|
||||
public int CosplayersMissingProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the top tags by asset count.
|
||||
|
||||
@@ -46,4 +46,13 @@ public class UserInfoDto {
|
||||
/// Gets or sets the access level of the user.
|
||||
/// </summary>
|
||||
public required EAccessLevel AccessLevel { get; set; } = EAccessLevel.User;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the IDs of persons (cosplayers) this user is a maintainer of.
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainedPersonIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the names of persons (cosplayers) this user is a maintainer of.
|
||||
/// </summary>
|
||||
public List<string>? MaintainedPersonNames { get; set; }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,8 @@ public string? OldPassword { get; set; }
|
||||
/// Gets or sets the new access level.
|
||||
/// </summary>
|
||||
public EAccessLevel? AccessLevel { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of person (cosplayer) IDs this user should maintain (admin only).
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainedPersonIds { get; set; }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ public enum DisplayType {
|
||||
TimePicker = 7,
|
||||
/// <summary>Power of 2 selection.</summary>
|
||||
Po2W = 8,
|
||||
/// <summary>User picker (searchable dropdown).</summary>
|
||||
UserSelect = 9,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -31,7 +31,9 @@ public enum Settings {
|
||||
/// <summary>Quality setting (1–100) for WebP thumbnail encoding.</summary>
|
||||
ThumbnailQuality,
|
||||
/// <summary>Quality setting (1–100) for WebP preview encoding.</summary>
|
||||
PreviewQuality
|
||||
PreviewQuality,
|
||||
/// <summary>User ID used for scanner-created asset attribution.</summary>
|
||||
SystemUploaderId
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -59,6 +61,7 @@ public static class SettingsExtensions {
|
||||
Settings.JobBatchSize => "Job Batch Size",
|
||||
Settings.ThumbnailQuality => "Thumbnail Quality",
|
||||
Settings.PreviewQuality => "Preview Quality",
|
||||
Settings.SystemUploaderId => "System Uploader Id",
|
||||
_ => setting.ToString() // Fallback to the enum name
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ namespace Butter.Types;
|
||||
/// </summary>
|
||||
public enum EAccessLevel {
|
||||
/// <summary>Standard user with basic permissions.</summary>
|
||||
User,
|
||||
User = 0,
|
||||
/// <summary>Maintainer — scoped cosplayer management and tag management.</summary>
|
||||
Maintainer = 1,
|
||||
/// <summary>Curator with elevated permissions for managing assets.</summary>
|
||||
Curator,
|
||||
Curator = 2,
|
||||
/// <summary>Administrator with full system access.</summary>
|
||||
Admin
|
||||
Admin = 3
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Butter.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Defines visibility levels for entities in the system.
|
||||
/// </summary>
|
||||
public enum EVisibility {
|
||||
/// <summary>Visible to everyone, including anonymous users.</summary>
|
||||
Public = 0,
|
||||
/// <summary>Visible to authenticated users only.</summary>
|
||||
Protected = 1,
|
||||
/// <summary>Visible only to authorized users (maintainers, curators, admins).</summary>
|
||||
Private = 2
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.6.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace Lactose.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for rate-limit buckets applied to different API route groups.
|
||||
/// </summary>
|
||||
public record RateLimitBucketOptions {
|
||||
/// <summary>
|
||||
/// The configuration section name for rate limiting.
|
||||
/// </summary>
|
||||
public const string SectionName = "RateLimiting";
|
||||
|
||||
/// <summary>
|
||||
/// Default bucket applied to all routes that are not explicitly assigned.
|
||||
/// </summary>
|
||||
public BucketOptions Global { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Strict bucket for authentication endpoints (login, register, refresh).
|
||||
/// </summary>
|
||||
public BucketOptions Auth { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bucket for serving original (full-size) media files.
|
||||
/// </summary>
|
||||
public BucketOptions MediaOriginal { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bucket for serving thumbnail media files.
|
||||
/// </summary>
|
||||
public BucketOptions MediaThumb { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bucket for serving preview media files.
|
||||
/// </summary>
|
||||
public BucketOptions MediaPreview { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bucket for the long-polling jobs endpoint. Uses a shorter window.
|
||||
/// </summary>
|
||||
public BucketOptions Jobs { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settings for an individual rate-limit bucket.
|
||||
/// </summary>
|
||||
public class BucketOptions {
|
||||
/// <summary>
|
||||
/// Maximum number of permits allowed within the window.
|
||||
/// </summary>
|
||||
public int PermitLimit { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Length of the fixed window in seconds.
|
||||
/// </summary>
|
||||
public int WindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of queued requests waiting for a permit. Null disables queueing.
|
||||
/// </summary>
|
||||
public int? QueueLimit { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Lactose.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configures <see cref="RateLimitBucketOptions"/> from the application configuration.
|
||||
/// </summary>
|
||||
public class RateLimitBucketOptionsProvider(IConfiguration configuration) : IConfigureOptions<RateLimitBucketOptions> {
|
||||
/// <inheritdoc />
|
||||
public void Configure(RateLimitBucketOptions options) {
|
||||
var section = configuration.GetSection(RateLimitBucketOptions.SectionName);
|
||||
if (!section.Exists())
|
||||
return;
|
||||
|
||||
section.Bind(options);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ public class LactoseDbContext : DbContext {
|
||||
/// Gets or sets the job records DbSet.
|
||||
/// </summary>
|
||||
public DbSet<JobRecord> JobRecords { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the person-maintainer join table.
|
||||
/// </summary>
|
||||
public DbSet<PersonMaintainer> PersonMaintainers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new instance of the <see cref="LactoseDbContext"/> class.
|
||||
@@ -62,20 +66,22 @@ public class LactoseDbContext : DbContext {
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
modelBuilder.HasPostgresExtension("pg_trgm");
|
||||
//Album Relationships
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.UserOwner).WithMany(e => e.OwnedAlbums);
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
|
||||
modelBuilder.Entity<Album>().HasMany(e => e.Assets).WithMany(e => e.Albums);
|
||||
//Asset Relationships
|
||||
modelBuilder.Entity<Asset>().HasOne(e => e.Owner).WithMany(e => e.OwnedAssets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.SharedWith).WithMany(e => e.SharedAssets);
|
||||
modelBuilder.Entity<Asset>().HasOne(e => e.Uploader).WithMany(e => e.UploadedAssets).HasForeignKey(e => e.UploadedBy);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Albums).WithMany(e => e.Assets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Tags).WithMany(e => e.Assets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Faces).WithOne(e => e.Asset);
|
||||
modelBuilder.Entity<Asset>().HasOne(e => e.Folder).WithMany(e => e.Assets);
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.IsPubliclyShared, e.OwnerId })
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(a => a.OriginalFilename)
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm")
|
||||
.HasMethod("gin")
|
||||
.HasOperators("gin_trgm_ops");
|
||||
// People
|
||||
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
|
||||
.HasDatabaseName("IX_People_Name_Trgm")
|
||||
@@ -93,12 +99,16 @@ public class LactoseDbContext : DbContext {
|
||||
modelBuilder.Entity<Person>().HasOne(e => e.ProfileAsset).WithMany().HasForeignKey(e => e.ProfileAssetId);
|
||||
modelBuilder.Entity<Person>().HasMany(e => e.Faces).WithOne(e => e.Person);
|
||||
modelBuilder.Entity<Person>().HasMany(e => e.Albums).WithOne(e => e.PersonOwner);
|
||||
modelBuilder.Entity<Person>().HasMany(e => e.Maintainers).WithMany(e => e.MaintainedPersons)
|
||||
.UsingEntity<PersonMaintainer>(
|
||||
j => j.HasOne(pm => pm.User).WithMany().HasForeignKey(pm => pm.UserId),
|
||||
j => j.HasOne(pm => pm.Person).WithMany().HasForeignKey(pm => pm.PersonId)
|
||||
);
|
||||
//Tag Relationships
|
||||
modelBuilder.Entity<Tag>().HasOne(e => e.Parent);
|
||||
modelBuilder.Entity<Tag>().HasMany(e => e.Assets).WithMany(e => e.Tags);
|
||||
//User Relationships
|
||||
modelBuilder.Entity<User>().HasMany(e => e.OwnedAssets).WithOne(e => e.Owner);
|
||||
modelBuilder.Entity<User>().HasMany(e => e.OwnedAlbums).WithOne(e => e.UserOwner);
|
||||
modelBuilder.Entity<User>().HasMany(e => e.UploadedAssets).WithOne(e => e.Uploader).HasForeignKey(e => e.UploadedBy);
|
||||
//Folder Relationships
|
||||
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace Lactose.Controllers;
|
||||
/// <param name="authService">Authentication service.</param>
|
||||
/// <param name="albumRepository">Album repository.</param>
|
||||
/// <param name="assetRepository">Asset repository.</param>
|
||||
/// <param name="personRepository">Person repository.</param>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
@@ -24,7 +25,8 @@ public class AlbumController(
|
||||
//ILogger<AlbumController> logger,
|
||||
LactoseAuthService authService,
|
||||
IAlbumRepository albumRepository,
|
||||
IAssetRepository assetRepository
|
||||
IAssetRepository assetRepository,
|
||||
IPersonRepository personRepository
|
||||
) : ControllerBase {
|
||||
/// <summary>
|
||||
/// Gets an album by its ID with filtered assets based on access level.
|
||||
@@ -36,37 +38,12 @@ public class AlbumController(
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.Find(id);
|
||||
var album = albumRepository.FindVisible(id, uid, accessLevel);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
// If the album has no assets, return an empty list
|
||||
if (album.Assets == null) return Ok(album.ToAlbumFullDto());
|
||||
|
||||
//Storage for assets that will be sent out;
|
||||
List<Asset> assets = new();
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User:
|
||||
// Only publicly shared assets or owned by user
|
||||
assets.AddRange(album.Assets.Where(asset => (asset.IsPubliclyShared || asset.OwnerId == uid) && asset.DeletedAt == null));
|
||||
// Add shared assets
|
||||
assets.AddRange(album.Assets.Where(asset => asset.SharedWith!.Any(user => user.Id == uid) && asset.DeletedAt == null));
|
||||
break;
|
||||
case EAccessLevel.Curator:
|
||||
// all assets minus deleted
|
||||
assets.AddRange(album.Assets.Where(asset => asset.DeletedAt == null));
|
||||
break;
|
||||
case EAccessLevel.Admin:
|
||||
// All assets
|
||||
assets.AddRange(album.Assets);
|
||||
break;
|
||||
}
|
||||
|
||||
// changes the retrieved album to the new filtered
|
||||
album.Assets = assets.OrderBy(a => a.OriginalFilename).ToList();
|
||||
return Ok(album.ToAlbumFullDto());
|
||||
return Ok(album.ToAlbumFullDto(accessLevel, uid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -89,7 +66,8 @@ public class AlbumController(
|
||||
pagingOptions.SortAsc,
|
||||
pagingOptions.Unassigned,
|
||||
uid ?? default,
|
||||
accessLevel
|
||||
accessLevel,
|
||||
pagingOptions.AssetUploadedBy
|
||||
).ToList();
|
||||
|
||||
return Ok(albums);
|
||||
@@ -102,7 +80,7 @@ public class AlbumController(
|
||||
/// <param name="albumDto">The album update data.</param>
|
||||
/// <returns>200 on success, 403 if forbidden, 404 if not found.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
@@ -112,22 +90,19 @@ public class AlbumController(
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
// Maintainer scope check — must maintain the album's person
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
|
||||
return Forbid();
|
||||
case EAccessLevel.User:
|
||||
// Users can't update albums they don't own
|
||||
if(album.UserOwnerId != uid) return Forbid();
|
||||
break;
|
||||
// Admins and Curator can update any album
|
||||
case EAccessLevel.Curator:
|
||||
case EAccessLevel.Admin:
|
||||
break;
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
// Update album properties
|
||||
album.Title = string.IsNullOrEmpty(albumDto.Name) ? album.Title : albumDto.Name;
|
||||
album.UpdatedAt = DateTime.UtcNow;
|
||||
album.PersonOwnerId = albumDto.RemovePerson ? null : albumDto.Person ?? album.PersonOwnerId;
|
||||
album.UserOwnerId = albumDto.Owner ?? album.UserOwnerId;
|
||||
album.Visibility = albumDto.Visibility ?? album.Visibility;
|
||||
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
|
||||
album.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
|
||||
|
||||
@@ -144,7 +119,7 @@ public class AlbumController(
|
||||
/// <param name="bulkDto">The bulk update data with album IDs and shared values.</param>
|
||||
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
@@ -155,9 +130,13 @@ public class AlbumController(
|
||||
case EAccessLevel.User:
|
||||
// Users can't update albums
|
||||
return Forbid();
|
||||
case EAccessLevel.Maintainer:
|
||||
// Maintainers can only update albums of persons they maintain
|
||||
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
|
||||
if (albums.Count == 0) return Forbid();
|
||||
break;
|
||||
case EAccessLevel.Curator:
|
||||
// Curators can only update albums they own
|
||||
albums = albums.Where(x => x.UserOwnerId == uid).ToList();
|
||||
// Curators can update any album
|
||||
break;
|
||||
case EAccessLevel.Admin:
|
||||
// Admins can update any album
|
||||
@@ -168,7 +147,7 @@ public class AlbumController(
|
||||
album.Title = bulkDto.Data.Name ?? album.Title;
|
||||
album.UpdatedAt = DateTime.UtcNow;
|
||||
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
|
||||
album.UserOwnerId = bulkDto.Data.Owner ?? album.UserOwnerId;
|
||||
album.Visibility = bulkDto.Data.Visibility ?? album.Visibility;
|
||||
album.Assets = bulkDto.Data.Assets != null ? assetRepository.FindBulk(bulkDto.Data.Assets).ToList() : album.Assets;
|
||||
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
|
||||
}
|
||||
@@ -185,14 +164,10 @@ public class AlbumController(
|
||||
/// <param name="albumDto">The album creation data.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public ActionResult Create([FromBody] AlbumCreateDto albumDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
// If not admin, the owner of a new album is the current user
|
||||
albumDto.Owner = accessLevel == EAccessLevel.Admin ? albumDto.Owner : uid;
|
||||
|
||||
var assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : new List<Asset>();
|
||||
|
||||
var album = new Album {
|
||||
@@ -200,7 +175,7 @@ public class AlbumController(
|
||||
Title = albumDto.Name,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
PersonOwnerId = albumDto.Person ?? null,
|
||||
UserOwnerId = albumDto.Owner,
|
||||
Visibility = albumDto.Visibility,
|
||||
CoverAssetId = albumDto.CoverAssetId ?? assets.OrderBy(a => a.OriginalFilename).FirstOrDefault()?.Id,
|
||||
Assets = assets
|
||||
};
|
||||
@@ -211,24 +186,49 @@ public class AlbumController(
|
||||
return Ok(album.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple source albums into a destination album.
|
||||
/// All assets from source albums are moved to the destination, then sources are hard-deleted.
|
||||
/// </summary>
|
||||
/// <param name="dto">The merge request with destination and source IDs.</param>
|
||||
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
|
||||
[HttpPost("merge")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public ActionResult Merge([FromBody] AlbumMergeDto dto) {
|
||||
if (dto.SourceIds.Contains(dto.DestinationId))
|
||||
return BadRequest("Destination album ID must not appear in source album IDs.");
|
||||
|
||||
if (albumRepository.Find(dto.DestinationId) == null)
|
||||
return NotFound("Destination album not found.");
|
||||
|
||||
albumRepository.MergeAlbums(dto.DestinationId, dto.SourceIds);
|
||||
albumRepository.Save();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes multiple albums (soft delete).
|
||||
/// </summary>
|
||||
/// <param name="albumIds">The list of album IDs to remove.</param>
|
||||
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult BulkDelete([FromBody] List<Guid> albumIds) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var albums = albumRepository.FindBulk(albumIds).ToList();
|
||||
|
||||
// If the user is not an admin, filter out albums they do not own
|
||||
if (accessLevel != EAccessLevel.Admin) { albums = albums.Where(x => x.UserOwnerId == uid).ToList(); }
|
||||
// Filter by access level
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer:
|
||||
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
|
||||
break;
|
||||
case EAccessLevel.User:
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
// If no albums are left after filtering, return a 403
|
||||
if (albumIds.Count == 0) return Forbid();
|
||||
if (albums.Count == 0) return Forbid();
|
||||
|
||||
albums.ForEach(albumRepository.Remove);
|
||||
albumRepository.Save();
|
||||
@@ -242,7 +242,7 @@ public class AlbumController(
|
||||
/// <param name="id">The album ID.</param>
|
||||
/// <returns>200 on success, 404 if not found, 403 if forbidden.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult Delete(Guid id) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
@@ -251,8 +251,13 @@ public class AlbumController(
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
// If the user is not the owner or an admin, return a 403
|
||||
if (accessLevel != EAccessLevel.Admin && album.UserOwnerId != uid) return Forbid();
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
|
||||
return Forbid();
|
||||
case EAccessLevel.User:
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
albumRepository.Remove(album);
|
||||
albumRepository.Save();
|
||||
|
||||
@@ -18,13 +18,15 @@ namespace Lactose.Controllers;
|
||||
/// <param name="authService">Authentication service.</param>
|
||||
/// <param name="assetRepository">Asset repository.</param>
|
||||
/// <param name="userRepository">User repository.</param>
|
||||
/// <param name="personRepository">Person repository (for maintainer scope checks).</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AssetController(
|
||||
ILogger<AssetController> logger,
|
||||
LactoseAuthService authService,
|
||||
IAssetRepository assetRepository,
|
||||
IUserRepository userRepository
|
||||
IUserRepository userRepository,
|
||||
IPersonRepository personRepository
|
||||
) : ControllerBase {
|
||||
/// <summary>
|
||||
/// Gets an asset by its ID with full details, respecting access level.
|
||||
@@ -44,50 +46,14 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
var asset = assetRepository.Find(id);
|
||||
var asset = assetRepository.FindVisible(id, uid, accessLevel);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
logger.LogWarning($"Request asset {id} not found or not visible!");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User: {
|
||||
//Not publicly shared && Not shared with the user
|
||||
if (!asset.IsPubliclyShared && asset.SharedWith?.Find(x => x.Id == uid) == null) {
|
||||
logger.LogTrace($"{asset.Id} is not shared with user {uid}");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
//Deleted asset
|
||||
if (asset.DeletedAt != null) {
|
||||
logger.LogTrace($"{asset.Id} is deleted and thus not exists for this user");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Curator: {
|
||||
//Deleted asset owned by the user
|
||||
if (asset.DeletedAt != null)
|
||||
if (asset.Owner?.Id != uid) {
|
||||
logger.LogTrace(
|
||||
$"{asset.Id} is deleted and it's not owned by this user so it does not exists for this user"
|
||||
);
|
||||
|
||||
return NotFound();
|
||||
} else { logger.LogTrace($"{asset.Id} is deleted but it's owned by this user so it exists for this user"); }
|
||||
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Admin: {
|
||||
//Admins will see this regardless
|
||||
logger.LogTrace($"{asset.Id} is being requested by an admin, sending it regardless of the visibility state");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
AssetDto dto = asset.ToFullAssetsDto(accessLevel);
|
||||
AssetDto dto = asset.ToFullAssetsDto(accessLevel, uid);
|
||||
|
||||
logger.LogTrace(
|
||||
$"""
|
||||
@@ -95,11 +61,11 @@ public class AssetController(
|
||||
dto.Id: {dto.Id}
|
||||
dto.FileName: {dto.FileName}
|
||||
dto.AssetType: {dto.AssetType}
|
||||
dto.IsPublic: {dto.IsPublic}
|
||||
dto.Visibility: {dto.Visibility}
|
||||
dto.CreatedAt: {dto.CreatedAt}
|
||||
dto.UpdatedAt: {dto.UpdatedAt}
|
||||
dto.DeletedAt: {dto.DeletedAt}
|
||||
dto.Owner: {dto.Owner.Id} {dto.Owner.Username}
|
||||
dto.UploadedBy: {dto.UploadedBy.Id} {dto.UploadedBy.Username}
|
||||
"""
|
||||
);
|
||||
|
||||
@@ -107,9 +73,9 @@ public class AssetController(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches assets with optional date range, type filter, and random ordering with pagination.
|
||||
/// Searches assets with optional date range, type filter, random ordering, unlinked filter, folder filter, uploader filter, search term, and pagination.
|
||||
/// </summary>
|
||||
/// <param name="searchOptionsDto">Search options including date range, type filter, random ordering, and pagination.</param>
|
||||
/// <param name="searchOptionsDto">Search options including date range, type filter, unlinked filter, folder filter, uploader filter, random ordering, search term, and pagination.</param>
|
||||
/// <returns>A list of asset previews.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
|
||||
@@ -131,7 +97,9 @@ public class AssetController(
|
||||
|
||||
var assets = assetRepository.GetAssets(
|
||||
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
|
||||
uid, accessLevel,
|
||||
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search
|
||||
);
|
||||
|
||||
logger.LogTrace(
|
||||
@@ -146,27 +114,56 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User: {
|
||||
assets = assets.Where(
|
||||
a => a.DeletedAt == null && (a.IsPubliclyShared || a.SharedWith?.Find(x => x.Id == uid) != null)
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Curator:
|
||||
case EAccessLevel.Admin: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var dtoList = assets.ToAssetPreviewDto().ToList();
|
||||
var dtoList = assets.ToAssetPreviewDto(accessLevel, uid).ToList();
|
||||
|
||||
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
|
||||
Response.Headers["X-Total-Count"] = total.ToString();
|
||||
return Ok(dtoList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
|
||||
/// Admins and curators receive all folders with their unlinked asset counts. Maintainers receive a single
|
||||
/// "My Uploads" group with their own unlinked upload count.
|
||||
/// </summary>
|
||||
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
|
||||
[HttpGet("unlinked-groups")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult<List<AssetGroupDto>> GetFolderGroups([FromQuery] bool unlinkedOnly = true) {
|
||||
var userData = authService.GetUserData(User);
|
||||
var uid = userData?.Id;
|
||||
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
logger.LogTrace("GetFolderGroups: uid={Uid}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", uid, accessLevel, unlinkedOnly);
|
||||
|
||||
var groups = assetRepository.GetFolderGroups(uid, accessLevel, unlinkedOnly);
|
||||
logger.LogTrace("GetFolderGroups: returned {Count} groups", groups.Count);
|
||||
return Ok(groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Browses assets at a specific directory level within a folder, returning immediate subdirectories
|
||||
/// and a paginated list of assets at that level.
|
||||
/// </summary>
|
||||
/// <param name="options">Browse options including folder ID, path, pagination, search, and unlinked-only filter.</param>
|
||||
/// <returns>A browse result with subdirectories and assets at the current level.</returns>
|
||||
[HttpGet("browse")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult<AssetBrowseResultDto> Browse([FromQuery] AssetBrowseOptionsDto options) {
|
||||
var userData = authService.GetUserData(User);
|
||||
var uid = userData?.Id;
|
||||
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (options.Page < 0 || options.PageSize < 1 || options.PageSize > PagedParametersDto.MaxPageSize)
|
||||
return BadRequest();
|
||||
|
||||
logger.LogTrace("Browse: folderId={FolderId}, path={Path}, page={Page}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", options.FolderId, options.Path, options.Page, accessLevel, options.UnlinkedOnly);
|
||||
|
||||
var result = assetRepository.BrowseAssets(options.FolderId, options.Path ?? "", options.Page, options.PageSize, uid, accessLevel, options.Search, options.UnlinkedOnly);
|
||||
if (result == null) return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an asset's metadata.
|
||||
/// </summary>
|
||||
@@ -174,32 +171,35 @@ public class AssetController(
|
||||
/// <param name="dto">The asset update data.</param>
|
||||
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var asset = assetRepository.Find(id);
|
||||
var asset = assetRepository.FindWithAlbums(id);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
|
||||
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
|
||||
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to update asset {id} that they do not own");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
string log = "";
|
||||
|
||||
asset.IsPubliclyShared = dto.IsPublic ?? asset.IsPubliclyShared;
|
||||
log += $"PubliclyShared: {asset.IsPubliclyShared} -> {dto.IsPublic ?? asset.IsPubliclyShared}\n";
|
||||
asset.Visibility = dto.Visibility ?? asset.Visibility;
|
||||
log += $"Visibility: {asset.Visibility} -> {dto.Visibility ?? asset.Visibility}\n";
|
||||
|
||||
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
|
||||
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
|
||||
|
||||
asset.Owner = dto.Owner != null ? userRepository.Find(dto.Owner.Value) : asset.Owner;
|
||||
log += $"Owner: {asset.Owner?.Id} -> {dto.Owner ?? asset.Owner?.Id}\n";
|
||||
if (accesslevel == EAccessLevel.Admin) {
|
||||
asset.UploadedBy = dto.UploadedBy ?? asset.UploadedBy;
|
||||
log += $"UploadedBy: {asset.UploadedBy} -> {dto.UploadedBy ?? asset.UploadedBy}\n";
|
||||
}
|
||||
|
||||
logger.LogTrace($"Updating asset {id}\n{log}");
|
||||
|
||||
@@ -215,7 +215,7 @@ public class AssetController(
|
||||
/// <param name="bulkDto">The bulk update data with asset IDs and shared values.</param>
|
||||
/// <returns>200 on success, or an error response.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
@@ -234,17 +234,24 @@ public class AssetController(
|
||||
}
|
||||
|
||||
//If the user is not an admin, they can only update their own assets
|
||||
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
|
||||
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
|
||||
: enumerable.Where(x => x.UploadedBy == uid).ToList();
|
||||
|
||||
enumerable.ForEach(
|
||||
x => {
|
||||
x.IsPubliclyShared = bulkDto.Data.IsPublic ?? x.IsPubliclyShared;
|
||||
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
|
||||
x.Owner = bulkDto.Data.Owner != null ? userRepository.Find(bulkDto.Data.Owner.Value) : x.Owner;
|
||||
x.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
|
||||
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
|
||||
|
||||
if (accesslevel == EAccessLevel.Admin) {
|
||||
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assetRepository.UpdateBulk(enumerable);
|
||||
assetRepository.Save();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -254,18 +261,19 @@ public class AssetController(
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var asset = assetRepository.Find(id);
|
||||
var asset = assetRepository.FindWithAlbums(id);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
|
||||
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
|
||||
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to delete asset {id} that they do not own");
|
||||
return Unauthorized();
|
||||
}
|
||||
@@ -283,7 +291,7 @@ public class AssetController(
|
||||
/// <param name="ids">The list of asset IDs to delete.</param>
|
||||
/// <returns>200 on success, or an error response.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
@@ -302,11 +310,15 @@ public class AssetController(
|
||||
}
|
||||
|
||||
//If the user is not an admin, they can only delete their own assets
|
||||
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
|
||||
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
|
||||
: enumerable.Where(x => x.UploadedBy == uid).ToList();
|
||||
|
||||
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
|
||||
|
||||
assetRepository.UpdateBulk(enumerable);
|
||||
assetRepository.Save();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -322,23 +334,22 @@ public class AssetController(
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
|
||||
if (userRepository.Find(dto.Owner) == null) {
|
||||
logger.LogWarning($"User {dto.Owner} does not exist in the database");
|
||||
return BadRequest();
|
||||
}
|
||||
if (dto.UploadedBy.HasValue) {
|
||||
if (userRepository.Find(dto.UploadedBy.Value) == null) {
|
||||
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
if (accesslevel != EAccessLevel.Admin && dto.Owner != uid) {
|
||||
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.Owner}");
|
||||
return Unauthorized();
|
||||
if (accesslevel != EAccessLevel.Admin && dto.UploadedBy != uid) {
|
||||
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.UploadedBy}");
|
||||
return Unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: This makes exactly zero sense. How are we supposed to create an asset externally here?
|
||||
// Does this even need to exist?
|
||||
var asset = new Asset {
|
||||
Id = Guid.NewGuid(),
|
||||
Type = dto.AssetType,
|
||||
IsPubliclyShared = dto.IsPublic,
|
||||
OwnerId = dto.Owner,
|
||||
Visibility = dto.Visibility,
|
||||
UploadedBy = dto.UploadedBy,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
MimeType = dto.MimeType,
|
||||
ResolutionWidth = dto.ResolutionWidth,
|
||||
@@ -355,4 +366,14 @@ public class AssetController(
|
||||
|
||||
return Ok(asset.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a Maintainer has access to an asset through the cosplayers they maintain.
|
||||
/// A Maintainer can access assets that are in any album belonging to a person they maintain.
|
||||
/// </summary>
|
||||
/// <param name="uid">The maintainer's user ID.</param>
|
||||
/// <param name="asset">The asset to check.</param>
|
||||
/// <returns><see langword="true"/> if the maintainer is linked via album→person→maintainer chain.</returns>
|
||||
private bool IsMaintainedByUser(Guid uid, Asset asset) =>
|
||||
asset.Albums?.Any(a => a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(uid, a.PersonOwnerId.Value)) == true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.User;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
@@ -7,6 +8,7 @@ using Lactose.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Lactose.Controllers;
|
||||
|
||||
@@ -17,13 +19,16 @@ namespace Lactose.Controllers;
|
||||
/// <param name="authService">Authentication service.</param>
|
||||
/// <param name="userRepository">User repository.</param>
|
||||
/// <param name="passwordHasher">Password hasher.</param>
|
||||
/// <param name="settingsRepository">Settings repository.</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[EnableRateLimiting("auth")]
|
||||
public class AuthController(
|
||||
ILogger<AuthController> logger,
|
||||
LactoseAuthService authService,
|
||||
IUserRepository userRepository,
|
||||
IPasswordHasher<User> passwordHasher
|
||||
IPasswordHasher<User> passwordHasher,
|
||||
ISettingsRepository settingsRepository
|
||||
) : ControllerBase {
|
||||
|
||||
/// <summary>
|
||||
@@ -96,10 +101,14 @@ public class AuthController(
|
||||
/// Registers a new user account.
|
||||
/// </summary>
|
||||
/// <param name="dto">The registration data.</param>
|
||||
/// <returns>200 with the new user ID, or 409 if the user already exists.</returns>
|
||||
/// <returns>200 with the new user ID, 403 if registration is disabled, or 409 if the user already exists.</returns>
|
||||
//TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token)
|
||||
[HttpPost("register")]
|
||||
public ActionResult Register([FromBody] UserRegisterDto dto) {
|
||||
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
|
||||
if (regSetting?.Value != "true")
|
||||
return StatusCode(403, "Registration is currently disabled on this server");
|
||||
|
||||
logger.LogDebug(
|
||||
$"""
|
||||
Registering user:
|
||||
|
||||
@@ -5,6 +5,7 @@ using Lactose.Jobs;
|
||||
using Lactose.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Lactose.Controllers;
|
||||
|
||||
@@ -17,6 +18,7 @@ namespace Lactose.Controllers;
|
||||
[ApiController]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[Route("api/[controller]")]
|
||||
[EnableRateLimiting("jobs")]
|
||||
public class JobsController(JobManager jobManager, JobScheduler jobScheduler, LactoseAuthService authService) : ControllerBase {
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,6 +4,7 @@ using Lactose.Repositories;
|
||||
using Lactose.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
@@ -35,6 +36,7 @@ public class MediaController(
|
||||
/// <returns>The physical image file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("{id}")]
|
||||
[HttpGet("original/{id}")]
|
||||
[EnableRateLimiting("media_original")]
|
||||
public ActionResult GetImage(Guid id) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
@@ -44,7 +46,7 @@ public class MediaController(
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
return PhysicalFile(asset.OriginalPath, asset.MimeType);
|
||||
}
|
||||
var data = mediaRepository.GetOriginalData(id, user?.Id);
|
||||
var data = mediaRepository.GetOriginalData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
@@ -56,6 +58,7 @@ public class MediaController(
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("thumb/{id}")]
|
||||
[EnableRateLimiting("media_thumb")]
|
||||
public ActionResult GetThumb(Guid id) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
@@ -66,7 +69,7 @@ public class MediaController(
|
||||
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
|
||||
return PhysicalFile(asset.ThumbnailPath, "image/webp");
|
||||
}
|
||||
var data = mediaRepository.GetThumbData(id, user?.Id);
|
||||
var data = mediaRepository.GetThumbData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
@@ -78,6 +81,7 @@ public class MediaController(
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("preview/{id}")]
|
||||
[EnableRateLimiting("media_preview")]
|
||||
public ActionResult GetPreview(Guid id) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
@@ -88,7 +92,7 @@ public class MediaController(
|
||||
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
|
||||
return PhysicalFile(asset.PreviewPath, "image/webp");
|
||||
}
|
||||
var data = mediaRepository.GetPreviewData(id, user?.Id);
|
||||
var data = mediaRepository.GetPreviewData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
|
||||
@@ -34,62 +34,12 @@ public class PersonController(
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var person = personRepository.Find(id);
|
||||
var person = personRepository.FindVisible(id, uid, accessLevel,
|
||||
albumPaging.Search, albumPaging.SortBy, albumPaging.SortAsc,
|
||||
albumPaging.Page, albumPaging.PageSize);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
if (person.Albums != null && accessLevel == EAccessLevel.User) {
|
||||
var owned = person.Albums.Where(a => a.UserOwnerId == uid).ToList();
|
||||
var publicShared = person.Albums
|
||||
.Where(a => a.Assets != null && a.Assets.Any(asset => asset is { IsPubliclyShared: true, DeletedAt: null }))
|
||||
.ToList();
|
||||
var shared = person.Albums
|
||||
.Where(a => a.Assets != null && a.Assets.Any(asset => asset is {
|
||||
DeletedAt: null, SharedWith: { Count: > 0 }
|
||||
} && asset.SharedWith!.Any(u => u.Id == uid)))
|
||||
.ToList();
|
||||
|
||||
person.Albums = owned
|
||||
.Union(publicShared)
|
||||
.Union(shared)
|
||||
.DistinctBy(a => a.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (person.Albums != null) {
|
||||
if (!string.IsNullOrEmpty(albumPaging.Search))
|
||||
{
|
||||
person.Albums = person.Albums
|
||||
.Where(a => a.Title.Contains(albumPaging.Search, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(albumPaging.SortBy))
|
||||
{
|
||||
person.Albums = albumPaging.SortBy.ToLowerInvariant() switch
|
||||
{
|
||||
"name" => albumPaging.SortAsc
|
||||
? [..person.Albums.OrderBy(a => a.Title)]
|
||||
: [..person.Albums.OrderByDescending(a => a.Title)],
|
||||
"created" => albumPaging.SortAsc
|
||||
? [..person.Albums.OrderBy(a => a.CreatedAt)]
|
||||
: [..person.Albums.OrderByDescending(a => a.CreatedAt)],
|
||||
"updated" => albumPaging.SortAsc
|
||||
? [..person.Albums.OrderBy(a => a.UpdatedAt)]
|
||||
: [..person.Albums.OrderByDescending(a => a.UpdatedAt)],
|
||||
"assets" => albumPaging.SortAsc
|
||||
? [..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
|
||||
};
|
||||
}
|
||||
|
||||
person.Albums = person.Albums
|
||||
.Skip(albumPaging.Page * albumPaging.PageSize)
|
||||
.Take(albumPaging.PageSize)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return Ok(person.ToPersonDetailedDto());
|
||||
return Ok(person.ToPersonDetailedDto(accessLevel, uid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -123,16 +73,31 @@ public class PersonController(
|
||||
/// <param name="dto">The updated person data.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var person = personRepository.Find(id);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
if (accessLevel == EAccessLevel.Maintainer) {
|
||||
if (uid == null || !personRepository.IsMaintainerOf(uid.Value, id))
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
person.Name = dto.Name;
|
||||
person.ProfileAssetId = dto.ProfileAssetId;
|
||||
person.ProfileCropX = dto.ProfileCropX;
|
||||
person.ProfileCropY = dto.ProfileCropY;
|
||||
person.ProfileCropZoom = dto.ProfileCropZoom;
|
||||
|
||||
if (dto.Visibility != null && accessLevel >= EAccessLevel.Maintainer)
|
||||
person.Visibility = dto.Visibility.Value;
|
||||
|
||||
if (dto.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||
personRepository.SetMaintainers(person.Id, dto.MaintainerUserIds);
|
||||
|
||||
person.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
personRepository.Save();
|
||||
@@ -147,6 +112,7 @@ public class PersonController(
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public IActionResult BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
|
||||
|
||||
foreach (var person in people) {
|
||||
@@ -155,7 +121,11 @@ public class PersonController(
|
||||
person.ProfileCropX = personBulkDto.Data.ProfileCropX;
|
||||
person.ProfileCropY = personBulkDto.Data.ProfileCropY;
|
||||
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom;
|
||||
person.Visibility = personBulkDto.Data.Visibility ?? person.Visibility;
|
||||
person.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
if (personBulkDto.Data.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||
personRepository.SetMaintainers(person.Id, personBulkDto.Data.MaintainerUserIds);
|
||||
}
|
||||
|
||||
personRepository.Save();
|
||||
@@ -215,6 +185,26 @@ public class PersonController(
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple source people into a destination person.
|
||||
/// Albums, faces, and maintainers are reassigned to the destination, then source people are hard-deleted.
|
||||
/// </summary>
|
||||
/// <param name="dto">The merge request with destination and source IDs.</param>
|
||||
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
|
||||
[HttpPost("merge")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public IActionResult Merge([FromBody] PersonMergeDto dto) {
|
||||
if (dto.SourceIds.Contains(dto.DestinationId))
|
||||
return BadRequest("Destination person ID must not appear in source person IDs.");
|
||||
|
||||
if (personRepository.Find(dto.DestinationId) == null)
|
||||
return NotFound("Destination person not found.");
|
||||
|
||||
personRepository.MergePeople(dto.DestinationId, dto.SourceIds);
|
||||
personRepository.Save();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new person.
|
||||
/// </summary>
|
||||
|
||||
@@ -92,7 +92,7 @@ public class TagController(
|
||||
/// <param name="id">The tag ID.</param>
|
||||
/// <param name="tagDto">The updated tag data.</param>
|
||||
/// <returns>200 on success, 404 if not found, 409 if name conflict.</returns>
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPost("{id}")]
|
||||
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) {
|
||||
|
||||
@@ -108,7 +108,7 @@ public class TagController(
|
||||
/// </summary>
|
||||
/// <param name="tagBulkDto">The bulk update data with IDs and shared update values.</param>
|
||||
/// <returns>200 on success, or the first error response.</returns>
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPost]
|
||||
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto) {
|
||||
|
||||
@@ -127,7 +127,7 @@ public class TagController(
|
||||
/// </summary>
|
||||
/// <param name="id">The tag ID.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult Delete([FromRoute] Guid id) {
|
||||
IStatusCodeActionResult result = DeleteTag(id);
|
||||
@@ -142,7 +142,7 @@ public class TagController(
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of tag IDs to delete.</param>
|
||||
/// <returns>200 on success, or the first error response.</returns>
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpDelete]
|
||||
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||
foreach (Guid id in ids) {
|
||||
@@ -160,7 +160,7 @@ public class TagController(
|
||||
/// </summary>
|
||||
/// <param name="tagDto">The tag creation data.</param>
|
||||
/// <returns>200 on success, 409 if a tag with the same name already exists, 400 if parent not found.</returns>
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPut]
|
||||
public ActionResult Create([FromBody] TagCreateDto tagDto) {
|
||||
|
||||
|
||||
@@ -83,26 +83,28 @@ namespace Lactose.Controllers {
|
||||
return Unauthorized("Unauthorized");
|
||||
}
|
||||
|
||||
//TODO: Lower level of access should fill less information of users
|
||||
logger.LogDebug($"Sending user with id {id}");
|
||||
|
||||
User? user = userRepository.Find(id);
|
||||
User? user = userRepository.FindWithMaintainedPersons(id);
|
||||
|
||||
if (user is null) return NotFound();
|
||||
|
||||
return Ok(user.ToGetUsersDto());
|
||||
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all users.
|
||||
/// Gets all users (admin/curator only).
|
||||
/// </summary>
|
||||
/// <returns>A list of all users.</returns>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[Authorize(Roles = "Admin,Curator")]
|
||||
[HttpGet]
|
||||
public ActionResult<List<UserInfoDto>> GetAll() {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
EAccessLevel viewerAccessLevel = authenticatedUser?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
logger.LogDebug("Sending Users list");
|
||||
|
||||
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto()).ToList());
|
||||
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
|
||||
}
|
||||
|
||||
|
||||
@@ -168,10 +170,14 @@ namespace Lactose.Controllers {
|
||||
}
|
||||
|
||||
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
|
||||
|
||||
if (dto.MaintainedPersonIds != null) {
|
||||
userRepository.SetMaintainedPersons(user.Id, dto.MaintainedPersonIds);
|
||||
}
|
||||
}
|
||||
|
||||
userRepository.Save();
|
||||
return Ok(user.ToGetUsersDto());
|
||||
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -195,7 +201,7 @@ namespace Lactose.Controllers {
|
||||
|
||||
if (user == null) return NotFound();
|
||||
|
||||
userRepository.Delete(user);
|
||||
user.DeletedAt = DateTime.UtcNow;
|
||||
|
||||
//TODO: eventually remove/hide all of the user content if required
|
||||
userRepository.Save();
|
||||
|
||||
@@ -131,5 +131,12 @@
|
||||
"Description": "Number of assets to process per sub-job batch.",
|
||||
"Type": 1,
|
||||
"DisplayType": 2
|
||||
},
|
||||
{
|
||||
"Name": "System Uploader Id",
|
||||
"Value": "",
|
||||
"Description": "User ID to attribute scanner-created assets to. Leave empty for no default owner.",
|
||||
"Type": 0,
|
||||
"DisplayType": 9
|
||||
}
|
||||
]
|
||||
+8
-4
@@ -8,16 +8,20 @@ EXPOSE 5162
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
ARG SixLaborsLicenseKey
|
||||
|
||||
WORKDIR "/src"
|
||||
COPY ["Lactose/Lactose.csproj", "Lactose/"]
|
||||
RUN dotnet restore "Lactose/Lactose.csproj"
|
||||
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||
dotnet restore "Lactose/Lactose.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/Lactose"
|
||||
RUN dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||
dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||
dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
@@ -17,6 +18,7 @@ public sealed class FileSystemCrawlJob : Job {
|
||||
/// <inheritdoc />
|
||||
public override JobStatus JobStatus { get; }
|
||||
IAssetRepository assetRepository;
|
||||
ISettingsRepository settingsRepository;
|
||||
string workingPath;
|
||||
Guid folderId;
|
||||
ILogger<FileSystemCrawlJob> logger;
|
||||
@@ -31,17 +33,20 @@ public sealed class FileSystemCrawlJob : Job {
|
||||
/// <param name="workingPath">The base path to crawl.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="assetRepository">The asset repository.</param>
|
||||
/// <param name="settingsRepository">The settings repository.</param>
|
||||
/// <param name="jobManager">The job manager.</param>
|
||||
public FileSystemCrawlJob(
|
||||
Guid folderId,
|
||||
string workingPath,
|
||||
ILogger<FileSystemCrawlJob> logger,
|
||||
IAssetRepository assetRepository,
|
||||
ISettingsRepository settingsRepository,
|
||||
JobManager jobManager
|
||||
) {
|
||||
JobStatus = new(this);
|
||||
this.logger = logger;
|
||||
this.assetRepository = assetRepository;
|
||||
this.settingsRepository = settingsRepository;
|
||||
this.workingPath = workingPath;
|
||||
this.folderId = folderId;
|
||||
this.jobManager = jobManager;
|
||||
@@ -64,6 +69,12 @@ public sealed class FileSystemCrawlJob : Job {
|
||||
int steps,
|
||||
totalSteps;
|
||||
|
||||
// Resolve system uploader from settings once
|
||||
Guid? uploaderId = null;
|
||||
var uploaderSetting = settingsRepository.Get(Settings.SystemUploaderId.AsString())?.Value;
|
||||
if (Guid.TryParse(uploaderSetting, out var parsed))
|
||||
uploaderId = parsed;
|
||||
|
||||
var files = dir.GetFiles();
|
||||
logger.LogDebug($"Found {files.Length} files.");
|
||||
|
||||
@@ -115,14 +126,21 @@ public sealed class FileSystemCrawlJob : Job {
|
||||
} else if (file.LastWriteTimeUtc > (existing.UpdatedAt ?? existing.CreatedAt)) {
|
||||
existing.FileSize = file.Length;
|
||||
existing.UpdatedAt = file.LastWriteTimeUtc;
|
||||
if (existing.UploadedBy == null && uploaderId.HasValue)
|
||||
existing.UploadedBy = uploaderId;
|
||||
assetRepository.Update(existing);
|
||||
assetRepository.Save();
|
||||
logger.LogInformation("Updated asset {Asset} — file modification detected.", existing.OriginalPath);
|
||||
} else if (existing.UploadedBy == null && uploaderId.HasValue) {
|
||||
existing.UploadedBy = uploaderId;
|
||||
assetRepository.Update(existing);
|
||||
assetRepository.Save();
|
||||
logger.LogInformation("Backfilled UploadedBy for asset {Asset}.", existing.OriginalPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var asset = AssetFromPath(file.FullName);
|
||||
var asset = AssetFromPath(file.FullName, uploaderId);
|
||||
if (asset == null) continue;
|
||||
|
||||
try {
|
||||
@@ -153,7 +171,7 @@ public sealed class FileSystemCrawlJob : Job {
|
||||
JobStatus.Complete("Crawl completed successfully.");
|
||||
}
|
||||
|
||||
Asset? AssetFromPath(string filePath) {
|
||||
Asset? AssetFromPath(string filePath, Guid? uploaderId) {
|
||||
logger.LogTrace("Loading asset from file path {filePath}", filePath);
|
||||
// Get the file info
|
||||
var finfo = new FileInfo(filePath);
|
||||
@@ -176,7 +194,8 @@ public sealed class FileSystemCrawlJob : Job {
|
||||
OriginalPath = finfo.FullName,
|
||||
OriginalFilename = finfo.Name,
|
||||
Type = type.Value,
|
||||
IsPubliclyShared = false,
|
||||
Visibility = EVisibility.Private,
|
||||
UploadedBy = uploaderId,
|
||||
CreatedAt = finfo.CreationTimeUtc,
|
||||
UpdatedAt = finfo.LastWriteTimeUtc,
|
||||
MimeType = type.Value switch {
|
||||
|
||||
@@ -14,19 +14,17 @@
|
||||
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.15">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.7.5" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -9,65 +9,50 @@ namespace Lactose.Mapper;
|
||||
/// </summary>
|
||||
public static class AlbumMapper {
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumPreviewDto object.
|
||||
/// Maps an Album object to an AlbumPreviewDto with visibility-aware fields.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to map.</param>
|
||||
/// <returns>An AlbumPreviewDto object.</returns>
|
||||
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album) => new AlbumPreviewDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Owner = album.UserOwnerId,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count ?? 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumPreviewDto with visibility-aware asset counting.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to map.</param>
|
||||
/// <param name="userId">The requesting user's ID (used for visibility filtering).</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>An AlbumPreviewDto object.</returns>
|
||||
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album, Guid userId, EAccessLevel accessLevel) => new AlbumPreviewDto {
|
||||
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album, EAccessLevel accessLevel, Guid? viewerId = null) => new AlbumPreviewDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Owner = album.UserOwnerId,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = accessLevel == EAccessLevel.User
|
||||
? album.Assets?.Count(asset =>
|
||||
asset.DeletedAt == null && (
|
||||
asset.IsPubliclyShared ||
|
||||
asset.OwnerId == userId ||
|
||||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId)))) ?? 0
|
||||
: album.Assets?.Count ?? 0
|
||||
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumFullDto object.
|
||||
/// </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) => new AlbumFullDto {
|
||||
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) => new AlbumFullDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Owner = album.UserOwnerId,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count ?? 0,
|
||||
Images = album.Assets!.Select(asset => asset.Id).ToList(),
|
||||
AssetPreviews = album.Assets!.Select(asset => new AlbumAssetPreviewDto {
|
||||
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 = asset.OriginalFilename
|
||||
}).ToList()
|
||||
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() ?? []
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Dtos.User;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
@@ -9,41 +10,45 @@ namespace Lactose.Mapper;
|
||||
/// </summary>
|
||||
public static class AssetsMapper {
|
||||
/// <summary>
|
||||
/// Maps an Asset object to a GetFullAssetDto object.
|
||||
/// Maps an Asset object to a AssetDto object.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to map.</param>
|
||||
/// <param name="accessLevel">The access level of the user.</param>
|
||||
/// <returns>A GetFullAssetDto object.</returns>
|
||||
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) => new AssetDto {
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>A AssetDto object.</returns>
|
||||
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetDto {
|
||||
Id = asset.Id,
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
AssetType = asset.Type,
|
||||
IsPublic = asset.IsPubliclyShared,
|
||||
CreatedAt = asset.CreatedAt,
|
||||
UpdatedAt = asset.UpdatedAt,
|
||||
DeletedAt = asset.DeletedAt,
|
||||
Owner = asset.Owner!.ToGetUsersDto(),
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null,
|
||||
UploadedBy = asset.Uploader?.ToGetUsersDto(accessLevel, viewerId)
|
||||
?? new UserInfoDto { Id = Guid.Empty, Username = "deleted", AccessLevel = EAccessLevel.User },
|
||||
MimeType = asset.MimeType,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
FileSize = asset.FileSize,
|
||||
Duration = asset.Duration ?? 0,
|
||||
FrameRate = asset.FrameRate ?? 0
|
||||
FrameRate = asset.FrameRate ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Asset object to a GetAssetPreviewDto object.
|
||||
/// Maps an Asset object to a AssetPreviewDto object.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to map.</param>
|
||||
/// <returns>A GetAssetPreviewDto object.</returns>
|
||||
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset) => new AssetPreviewDto {
|
||||
/// <param name="accessLevel">The access level of the user.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>A AssetPreviewDto object.</returns>
|
||||
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
MimeType = asset.MimeType,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
FileName = asset.OriginalFilename,
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
AlbumNames = asset.Albums?.Select(a => a.Title).Distinct().ToList(),
|
||||
AlbumIds = asset.Albums?.Select(a => a.Id).Distinct().ToList(),
|
||||
CosplayerNames = asset.Albums?
|
||||
@@ -55,14 +60,18 @@ public static class AssetsMapper {
|
||||
.Where(a => a.PersonOwner != null)
|
||||
.Select(a => a.PersonOwner!.Id)
|
||||
.Distinct()
|
||||
.ToList()
|
||||
.ToList(),
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps a collection of Asset objects to a collection of GetAssetPreviewDto objects.
|
||||
/// Maps a collection of Asset objects to a collection of AssetPreviewDto objects.
|
||||
/// </summary>
|
||||
/// <param name="assets">The collection of assets to map.</param>
|
||||
/// <returns>A collection of GetAssetPreviewDto objects.</returns>
|
||||
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets)
|
||||
=> assets.Select(ToAssetPreviewDto);
|
||||
/// <param name="accessLevel">The access level of the user.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>A collection of AssetPreviewDto objects.</returns>
|
||||
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets, EAccessLevel accessLevel, Guid? viewerId)
|
||||
=> assets.Select(a => a.ToAssetPreviewDto(accessLevel, viewerId));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Dtos.Person;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Mapper;
|
||||
@@ -12,9 +13,12 @@ public static class PersonMapper {
|
||||
/// Maps a Person to a PersonDetailedDto with album previews and stats.
|
||||
/// </summary>
|
||||
/// <param name="person">The person to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>A PersonDetailedDto.</returns>
|
||||
public static PersonDetailedDto ToPersonDetailedDto(this Person person) {
|
||||
public static PersonDetailedDto ToPersonDetailedDto(this Person person, EAccessLevel accessLevel, Guid? viewerId = null) {
|
||||
var albums = person.Albums ?? [];
|
||||
var maintainers = person.Maintainers ?? [];
|
||||
|
||||
return new PersonDetailedDto {
|
||||
Id = person.Id,
|
||||
@@ -25,7 +29,10 @@ public static class PersonMapper {
|
||||
ProfileCropZoom = person.ProfileCropZoom,
|
||||
TotalAlbums = albums.Count,
|
||||
TotalAssets = albums.Sum(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0),
|
||||
Albums = albums.Select(ToAlbumPreviewDto).ToList()
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null,
|
||||
Albums = albums.Select(a => a.ToAlbumPreviewDto(accessLevel, viewerId)).ToList(),
|
||||
MaintainerUserIds = maintainers.Select(u => u.Id).ToList(),
|
||||
MaintainerUsernames = maintainers.Select(u => u.Username).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,24 +40,16 @@ public static class PersonMapper {
|
||||
/// Maps a Person to a PersonPreviewDto.
|
||||
/// </summary>
|
||||
/// <param name="person">The person to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>A PersonPreviewDto.</returns>
|
||||
public static PersonPreviewDto ToPersonPreviewDto(this Person person) => new() {
|
||||
public static PersonPreviewDto ToPersonPreviewDto(this Person person, EAccessLevel accessLevel) => new() {
|
||||
Id = person.Id,
|
||||
Name = person.Name,
|
||||
ProfileAssetId = person.ProfileAssetId,
|
||||
ProfileCropX = person.ProfileCropX,
|
||||
ProfileCropY = person.ProfileCropY,
|
||||
ProfileCropZoom = person.ProfileCropZoom,
|
||||
TotalAlbums = person.Albums?.Count ?? 0
|
||||
};
|
||||
|
||||
private static AlbumPreviewDto ToAlbumPreviewDto(Album album) => new() {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Owner = album.UserOwnerId,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count(asset => asset.DeletedAt == null) ?? 0
|
||||
TotalAlbums = person.Albums?.Count ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.User;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Mapper;
|
||||
@@ -8,22 +9,33 @@ namespace Lactose.Mapper;
|
||||
/// </summary>
|
||||
public static class UsersMapper {
|
||||
/// <summary>
|
||||
/// Maps a User object to a UserInfoDto object.
|
||||
/// Maps a User object to a UserInfoDto object, filtering sensitive fields based on the viewer's access.
|
||||
/// Sensitive fields (Email, Ban/Delete status, AccessLevel, MaintainedPersons) are only populated
|
||||
/// when the viewer is an admin or viewing their own profile.
|
||||
/// </summary>
|
||||
/// <param name="user">The User object to map.</param>
|
||||
/// <param name="viewerAccessLevel">The access level of the requesting user.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user (null if anonymous).</param>
|
||||
/// <returns>A UserInfoDto object containing the mapped data.</returns>
|
||||
public static UserInfoDto ToGetUsersDto(this User user) {
|
||||
public static UserInfoDto ToGetUsersDto(this User user, EAccessLevel viewerAccessLevel, Guid? viewerId) {
|
||||
bool isAdmin = viewerAccessLevel >= EAccessLevel.Admin;
|
||||
bool isSelf = viewerId == user.Id;
|
||||
bool showSensitive = isAdmin || isSelf;
|
||||
|
||||
return new UserInfoDto {
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
Username = user.Username,
|
||||
CreatedAt = user.CreatedAt,
|
||||
IsBanned = user.BannedAt != null,
|
||||
BannedAt = user.BannedAt,
|
||||
LastLogin = user.LastLogin,
|
||||
UpdatedAt = user.UpdatedAt,
|
||||
DeletedAt = user.DeletedAt,
|
||||
AccessLevel = user.AccessLevel,
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
Email = showSensitive ? user.Email : null,
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = showSensitive ? user.UpdatedAt : null,
|
||||
LastLogin = showSensitive ? user.LastLogin : null,
|
||||
BannedAt = showSensitive ? user.BannedAt : null,
|
||||
DeletedAt = isAdmin ? user.DeletedAt : null,
|
||||
IsBanned = showSensitive && user.BannedAt != null,
|
||||
AccessLevel = showSensitive ? user.AccessLevel : EAccessLevel.User,
|
||||
MaintainedPersonIds = showSensitive ? user.MaintainedPersons?.Select(p => p.Id).ToList() : null,
|
||||
MaintainedPersonNames = showSensitive ? user.MaintainedPersons?.Select(p => p.Name).ToList() : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+591
@@ -0,0 +1,591 @@
|
||||
// <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("20260714154509_AuthRedesign_VisibilityAccessLevel")]
|
||||
partial class AuthRedesign_VisibilityAccessLevel
|
||||
{
|
||||
/// <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("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
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,229 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AuthRedesign_VisibilityAccessLevel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Albums_Users_UserOwnerId",
|
||||
table: "Albums");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Assets_Users_OwnerId",
|
||||
table: "Assets");
|
||||
|
||||
// Remap old EAccessLevel values: old Admin (2) → new Admin (3) first, then old Curator (1) → new Curator (2)
|
||||
migrationBuilder.Sql("UPDATE \"Users\" SET \"AccessLevel\" = 3 WHERE \"AccessLevel\" = 2");
|
||||
migrationBuilder.Sql("UPDATE \"Users\" SET \"AccessLevel\" = 2 WHERE \"AccessLevel\" = 1");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AssetUser");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_VisibleForSearch",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Albums_UserOwnerId",
|
||||
table: "Albums");
|
||||
|
||||
// Add Visibility columns with default Protected (1) before dropping IsPubliclyShared,
|
||||
// so we can map old values via SQL
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Visibility",
|
||||
table: "People",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Visibility",
|
||||
table: "Assets",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Visibility",
|
||||
table: "Albums",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
// Map old IsPubliclyShared = true assets back to Public (0)
|
||||
// Assets that were private default to Protected (1) from the column default
|
||||
migrationBuilder.Sql("UPDATE \"Assets\" SET \"Visibility\" = 0 WHERE \"IsPubliclyShared\" = true");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsPubliclyShared",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UserOwnerId",
|
||||
table: "Albums");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "OwnerId",
|
||||
table: "Assets",
|
||||
newName: "UploadedBy");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Assets_OwnerId",
|
||||
table: "Assets",
|
||||
newName: "IX_Assets_UploadedBy");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PersonMaintainers",
|
||||
columns: table => new
|
||||
{
|
||||
PersonId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PersonMaintainers", x => new { x.PersonId, x.UserId });
|
||||
table.ForeignKey(
|
||||
name: "FK_PersonMaintainers_People_PersonId",
|
||||
column: x => x.PersonId,
|
||||
principalTable: "People",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PersonMaintainers_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_VisibleForSearch",
|
||||
table: "Assets",
|
||||
columns: new[] { "Visibility", "UploadedBy" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PersonMaintainers_UserId",
|
||||
table: "PersonMaintainers",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Assets_Users_UploadedBy",
|
||||
table: "Assets",
|
||||
column: "UploadedBy",
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Assets_Users_UploadedBy",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PersonMaintainers");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_VisibleForSearch",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Visibility",
|
||||
table: "People");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Visibility",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Visibility",
|
||||
table: "Albums");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "UploadedBy",
|
||||
table: "Assets",
|
||||
newName: "OwnerId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Assets_UploadedBy",
|
||||
table: "Assets",
|
||||
newName: "IX_Assets_OwnerId");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsPubliclyShared",
|
||||
table: "Assets",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "UserOwnerId",
|
||||
table: "Albums",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AssetUser",
|
||||
columns: table => new
|
||||
{
|
||||
SharedAssetsId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SharedWithId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AssetUser", x => new { x.SharedAssetsId, x.SharedWithId });
|
||||
table.ForeignKey(
|
||||
name: "FK_AssetUser_Assets_SharedAssetsId",
|
||||
column: x => x.SharedAssetsId,
|
||||
principalTable: "Assets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AssetUser_Users_SharedWithId",
|
||||
column: x => x.SharedWithId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_VisibleForSearch",
|
||||
table: "Assets",
|
||||
columns: new[] { "IsPubliclyShared", "OwnerId" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Albums_UserOwnerId",
|
||||
table: "Albums",
|
||||
column: "UserOwnerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AssetUser_SharedWithId",
|
||||
table: "AssetUser",
|
||||
column: "SharedWithId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Albums_Users_UserOwnerId",
|
||||
table: "Albums",
|
||||
column: "UserOwnerId",
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Assets_Users_OwnerId",
|
||||
table: "Assets",
|
||||
column: "OwnerId",
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
// <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("20260716185022_AddUnlinkedAssetFilters")]
|
||||
partial class AddUnlinkedAssetFilters
|
||||
{
|
||||
/// <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", "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,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUnlinkedAssetFilters : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_OriginalFilename_Trgm",
|
||||
table: "Assets",
|
||||
column: "OriginalFilename")
|
||||
.Annotation("Npgsql:IndexMethod", "gin")
|
||||
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_OriginalFilename_Trgm",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,21 +55,6 @@ namespace Lactose.Migrations
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.Property<Guid>("SharedAssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("SharedWithId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SharedAssetsId", "SharedWithId");
|
||||
|
||||
b.HasIndex("SharedWithId");
|
||||
|
||||
b.ToTable("AssetUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -92,8 +77,8 @@ namespace Lactose.Migrations
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UserOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
@@ -107,8 +92,6 @@ namespace Lactose.Migrations
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("UserOwnerId");
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
@@ -140,9 +123,6 @@ namespace Lactose.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<bool>("IsPubliclyShared")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
@@ -155,9 +135,6 @@ namespace Lactose.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("OwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
@@ -192,16 +169,28 @@ namespace Lactose.Migrations
|
||||
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("OwnerId");
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("IsPubliclyShared", "OwnerId")
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
@@ -339,6 +328,9 @@ namespace Lactose.Migrations
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
@@ -352,6 +344,21 @@ namespace Lactose.Migrations
|
||||
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")
|
||||
@@ -478,21 +485,6 @@ namespace Lactose.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedAssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedWithId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
@@ -503,15 +495,9 @@ namespace Lactose.Migrations
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "UserOwner")
|
||||
.WithMany("OwnedAlbums")
|
||||
.HasForeignKey("UserOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
|
||||
b.Navigation("UserOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
@@ -520,13 +506,13 @@ namespace Lactose.Migrations
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Owner")
|
||||
.WithMany("OwnedAssets")
|
||||
.HasForeignKey("OwnerId");
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Owner");
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
@@ -553,6 +539,25 @@ namespace Lactose.Migrations
|
||||
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")
|
||||
@@ -581,9 +586,7 @@ namespace Lactose.Migrations
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OwnedAlbums");
|
||||
|
||||
b.Navigation("OwnedAssets");
|
||||
b.Navigation("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
|
||||
+6
-11
@@ -1,3 +1,4 @@
|
||||
using Butter.Types;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
@@ -30,12 +31,6 @@ public class Album {
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user owner identifier.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(UserOwner))]
|
||||
public Guid? UserOwnerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the person owner identifier.
|
||||
/// </summary>
|
||||
@@ -48,13 +43,13 @@ public class Album {
|
||||
[ForeignKey(nameof(CoverAsset))]
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level of the album.
|
||||
/// </summary>
|
||||
public EVisibility Visibility { get; set; }
|
||||
|
||||
#region Navigation Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user owner of the album. This is the user account who created/owns the album.
|
||||
/// </summary>
|
||||
public User? UserOwner { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the person owner of the album. This is the person/model who appears in the album.
|
||||
/// </summary>
|
||||
|
||||
+7
-12
@@ -75,15 +75,15 @@ public class Asset {
|
||||
public required EAssetType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the asset.
|
||||
/// Gets or sets the ID of the user who uploaded the asset.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(Owner))]
|
||||
public Guid? OwnerId { get; set; }
|
||||
[ForeignKey(nameof(UploadedBy))]
|
||||
public Guid? UploadedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the asset is publicly shared.
|
||||
/// Gets or sets the visibility level of the asset.
|
||||
/// </summary>
|
||||
public bool IsPubliclyShared { get; set; }
|
||||
public EVisibility Visibility { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the creation date of the asset.
|
||||
@@ -147,14 +147,9 @@ public class Asset {
|
||||
#region Navigation Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the owner of the asset.
|
||||
/// Gets or sets the user who uploaded the asset.
|
||||
/// </summary>
|
||||
public User? Owner { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of users with whom the asset is shared.
|
||||
/// </summary>
|
||||
public List<User>? SharedWith { get; set; }
|
||||
public User? Uploader { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of albums associated with the asset.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Types;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
@@ -50,6 +51,11 @@ public class Person {
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level of the person.
|
||||
/// </summary>
|
||||
public EVisibility Visibility { get; set; }
|
||||
|
||||
#region Navigation Properties
|
||||
|
||||
/// <summary>
|
||||
@@ -67,5 +73,10 @@ public class Person {
|
||||
/// </summary>
|
||||
public List<Album>? Albums { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of users who maintain this cosplayer.
|
||||
/// </summary>
|
||||
public List<User>? Maintainers { get; set; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Lactose.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the many-to-many relationship between maintainer users and the cosplayers they maintain.
|
||||
/// </summary>
|
||||
[PrimaryKey(nameof(PersonId), nameof(UserId))]
|
||||
public class PersonMaintainer {
|
||||
/// <summary>
|
||||
/// Gets or sets the person (cosplayer) ID.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(Person))]
|
||||
public Guid PersonId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the person (cosplayer).
|
||||
/// </summary>
|
||||
public Person Person { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maintainer user ID.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(User))]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maintainer user.
|
||||
/// </summary>
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
@@ -78,19 +78,14 @@ public class User {
|
||||
#region Navigation Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of assets owned by the user.
|
||||
/// Gets or sets the list of assets uploaded by the user.
|
||||
/// </summary>
|
||||
public List<Asset>? OwnedAssets { get; set; }
|
||||
public List<Asset>? UploadedAssets { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of albums owned by the user.
|
||||
/// Gets or sets the list of people (cosplayers) maintained by this user.
|
||||
/// </summary>
|
||||
public List<Album>? OwnedAlbums { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of assets shared with the user.
|
||||
/// </summary>
|
||||
public IEnumerable<Asset>? SharedAssets { get; set; }
|
||||
public List<Person>? MaintainedPersons { get; set; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Threading.RateLimiting;
|
||||
using Lactose.Authorization;
|
||||
using Lactose.Configuration;
|
||||
using Lactose.Context;
|
||||
@@ -9,6 +11,7 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi;
|
||||
using Npgsql;
|
||||
@@ -48,6 +51,68 @@ builder.Services.AddCors(options => {
|
||||
});
|
||||
|
||||
builder.Services.ConfigureOptions<SignKeyProvider>();
|
||||
builder.Services.ConfigureOptions<RateLimitBucketOptionsProvider>();
|
||||
builder.Services.AddRateLimiter(options => {
|
||||
options.RejectionStatusCode = (int)HttpStatusCode.TooManyRequests;
|
||||
|
||||
options.OnRejected = (context, cancellationToken) => {
|
||||
context.HttpContext.Response.ContentType = "application/json";
|
||||
var retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfterValue)
|
||||
? (int)retryAfterValue.TotalSeconds
|
||||
: 60;
|
||||
context.HttpContext.Response.Headers.RetryAfter = retryAfter.ToString();
|
||||
return new ValueTask(context.HttpContext.Response.WriteAsJsonAsync(new {
|
||||
error = "rate limit exceeded",
|
||||
retryAfter
|
||||
}, cancellationToken));
|
||||
};
|
||||
|
||||
var bucketConfig = builder.Configuration
|
||||
.GetSection(RateLimitBucketOptions.SectionName)
|
||||
.Get<RateLimitBucketOptions>() ?? new RateLimitBucketOptions();
|
||||
|
||||
options.AddFixedWindowLimiter("global", opt => {
|
||||
opt.PermitLimit = bucketConfig.Global.PermitLimit;
|
||||
opt.Window = TimeSpan.FromSeconds(bucketConfig.Global.WindowSeconds);
|
||||
opt.QueueLimit = bucketConfig.Global.QueueLimit ?? 0;
|
||||
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
});
|
||||
|
||||
options.AddFixedWindowLimiter("auth", opt => {
|
||||
opt.PermitLimit = bucketConfig.Auth.PermitLimit;
|
||||
opt.Window = TimeSpan.FromSeconds(bucketConfig.Auth.WindowSeconds);
|
||||
opt.QueueLimit = bucketConfig.Auth.QueueLimit ?? 0;
|
||||
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
});
|
||||
|
||||
options.AddFixedWindowLimiter("media_original", opt => {
|
||||
opt.PermitLimit = bucketConfig.MediaOriginal.PermitLimit;
|
||||
opt.Window = TimeSpan.FromSeconds(bucketConfig.MediaOriginal.WindowSeconds);
|
||||
opt.QueueLimit = bucketConfig.MediaOriginal.QueueLimit ?? 0;
|
||||
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
});
|
||||
|
||||
options.AddFixedWindowLimiter("media_thumb", opt => {
|
||||
opt.PermitLimit = bucketConfig.MediaThumb.PermitLimit;
|
||||
opt.Window = TimeSpan.FromSeconds(bucketConfig.MediaThumb.WindowSeconds);
|
||||
opt.QueueLimit = bucketConfig.MediaThumb.QueueLimit ?? 0;
|
||||
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
});
|
||||
|
||||
options.AddFixedWindowLimiter("media_preview", opt => {
|
||||
opt.PermitLimit = bucketConfig.MediaPreview.PermitLimit;
|
||||
opt.Window = TimeSpan.FromSeconds(bucketConfig.MediaPreview.WindowSeconds);
|
||||
opt.QueueLimit = bucketConfig.MediaPreview.QueueLimit ?? 0;
|
||||
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
});
|
||||
|
||||
options.AddFixedWindowLimiter("jobs", opt => {
|
||||
opt.PermitLimit = bucketConfig.Jobs.PermitLimit;
|
||||
opt.Window = TimeSpan.FromSeconds(bucketConfig.Jobs.WindowSeconds);
|
||||
opt.QueueLimit = bucketConfig.Jobs.QueueLimit ?? 0;
|
||||
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
});
|
||||
});
|
||||
|
||||
string? user = builder.Configuration["DatabaseCredentials:UserID"];
|
||||
string? password = builder.Configuration["DatabaseCredentials:Password"];
|
||||
@@ -178,6 +243,7 @@ if (app.Environment.IsDevelopment()) {
|
||||
}
|
||||
|
||||
app.UseCors("MilkStreamPolicy");
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
@@ -26,7 +27,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
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;
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
@@ -35,6 +36,17 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
if (unassigned)
|
||||
albumsQuery = albumsQuery.Where(a => a.PersonOwnerId == null);
|
||||
|
||||
if (uploadedBy.HasValue)
|
||||
albumsQuery = albumsQuery.Where(a => a.Assets!.Any(asset => asset.UploadedBy == uploadedBy.Value));
|
||||
|
||||
// Apply visibility filter scoped to access level
|
||||
albumsQuery = accessLevel switch {
|
||||
< EAccessLevel.Maintainer => albumsQuery.Where(a => a.Visibility <= EVisibility.Protected),
|
||||
EAccessLevel.Maintainer => albumsQuery.Where(a => a.Visibility <= EVisibility.Protected
|
||||
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == a.PersonOwnerId)),
|
||||
_ => albumsQuery
|
||||
};
|
||||
|
||||
IOrderedQueryable<Album> ordered = sortBy?.ToLower() switch
|
||||
{
|
||||
"name" => sortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title),
|
||||
@@ -56,26 +68,37 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
if (albumIds.Count == 0)
|
||||
return [];
|
||||
|
||||
// Load with includes for client-side visibility filtering
|
||||
var pagedAlbums = context.Albums
|
||||
.Include(a => a.Assets)!.ThenInclude(a => a.SharedWith)
|
||||
.Include(a => a.PersonOwner)
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.ToList();
|
||||
|
||||
// Filter out albums with no visible content for regular users
|
||||
if (accessLevel == EAccessLevel.User)
|
||||
pagedAlbums = pagedAlbums.Where(a =>
|
||||
a.Assets != null && a.Assets.Any(asset =>
|
||||
asset.DeletedAt == null && (
|
||||
asset.IsPubliclyShared ||
|
||||
asset.OwnerId == userId ||
|
||||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
|
||||
// Compute visible asset count per album at the DB level
|
||||
Dictionary<Guid, int> assetCounts;
|
||||
if (accessLevel >= EAccessLevel.Curator) {
|
||||
assetCounts = context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, Count = a.Assets!.Count() })
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
} else {
|
||||
assetCounts = 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 != default) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
))
|
||||
).ToList();
|
||||
})
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
}
|
||||
|
||||
// Project to DTOs
|
||||
var dtos = pagedAlbums.Select(a => a.ToAlbumPreviewDto(userId, accessLevel)).ToList();
|
||||
var dtos = pagedAlbums.Select(a => {
|
||||
var dto = a.ToAlbumPreviewDto(accessLevel, userId);
|
||||
dto.AssetCount = assetCounts.GetValueOrDefault(a.Id, 0);
|
||||
return dto;
|
||||
}).ToList();
|
||||
|
||||
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
|
||||
@@ -86,13 +109,62 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? Find(Guid id) => context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.CoverAsset)
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.Assets)!.ThenInclude(a => a.SharedWith)
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Album> FindByOwner(Guid ownerId) => context.Albums.Where(a => a.UserOwnerId == ownerId);
|
||||
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
var album = context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.CoverAsset)
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
if (album == null) return null;
|
||||
|
||||
// R3: Gate album visibility
|
||||
bool canSeeAlbum = accessLevel switch {
|
||||
< EAccessLevel.Maintainer => album.Visibility <= EVisibility.Protected,
|
||||
EAccessLevel.Maintainer when userId.HasValue => album.Visibility <= EVisibility.Protected
|
||||
|| (album.PersonOwnerId.HasValue && context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value)),
|
||||
EAccessLevel.Maintainer => album.Visibility <= EVisibility.Protected,
|
||||
_ => true
|
||||
};
|
||||
|
||||
if (!canSeeAlbum) return null;
|
||||
|
||||
if (album.Assets == null) return album;
|
||||
|
||||
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 && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
|
||||
)).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);
|
||||
@@ -104,6 +176,33 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to)
|
||||
=> context.Albums.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MergeAlbums(Guid destId, List<Guid> sourceIds) {
|
||||
var dest = context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == destId);
|
||||
if (dest == null) return;
|
||||
|
||||
var sources = context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Assets)
|
||||
.Where(a => sourceIds.Contains(a.Id))
|
||||
.ToList();
|
||||
|
||||
var existingAssetIds = dest.Assets?.Select(a => a.Id).ToHashSet() ?? [];
|
||||
|
||||
foreach (var source in sources) {
|
||||
if (source.Assets == null) continue;
|
||||
foreach (var asset in source.Assets) {
|
||||
if (existingAssetIds.Add(asset.Id))
|
||||
dest.Assets?.Add(asset);
|
||||
}
|
||||
}
|
||||
|
||||
context.Albums.RemoveRange(sources);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Remove(Album album) => context.Albums.Remove(album);
|
||||
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using System.Collections;
|
||||
using System.Data;
|
||||
using System.Text;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository> logger) : IAssetRepository {
|
||||
|
||||
/// <inheritdoc />
|
||||
public Asset? Find(Guid id) => context.Assets.Find(id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
IQueryable<Asset> query = context.Assets
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Uploader)
|
||||
.Where(a => a.Id == id);
|
||||
|
||||
return accessLevel switch {
|
||||
>= EAccessLevel.Admin => query.FirstOrDefault(),
|
||||
EAccessLevel.Curator => query.FirstOrDefault(a =>
|
||||
a.DeletedAt == null || a.UploadedBy == userId),
|
||||
EAccessLevel.Maintainer when userId.HasValue => query
|
||||
.Include(a => a.Albums)
|
||||
.FirstOrDefault(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
a.Visibility == EVisibility.Protected ||
|
||||
a.UploadedBy == userId ||
|
||||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
|
||||
context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId.Value && pm.PersonId == al.PersonOwnerId.Value))
|
||||
)),
|
||||
_ => query.FirstOrDefault(a =>
|
||||
a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(a.Visibility == EVisibility.Private && userId.HasValue && a.UploadedBy == userId)
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Asset? FindWithAlbums(Guid id) => context.Assets
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to) =>
|
||||
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
|
||||
@@ -49,10 +89,13 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> FindByOwner(Guid ownerId) => context.Assets.Where(a => a.Owner!.Id == ownerId);
|
||||
public IEnumerable<Asset> FindByUploader(Guid uploaderId) => context.Assets.Where(a => a.UploadedBy == uploaderId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets.Where(a => ids.Contains(a.Id));
|
||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums)
|
||||
.Where(a => ids.Contains(a.Id));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Asset? FindByPath(string path) => context.Assets.FirstOrDefault(a => a.OriginalPath == path);
|
||||
@@ -134,7 +177,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
.Take(limit);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total) {
|
||||
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();
|
||||
if (type.HasValue)
|
||||
query = query.Where(a => a.Type == type.Value);
|
||||
@@ -142,6 +185,33 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
query = query.Where(a => a.CreatedAt >= from.Value);
|
||||
if (to.HasValue)
|
||||
query = query.Where(a => a.UpdatedAt <= to.Value);
|
||||
if (unlinked)
|
||||
query = query.Where(a => a.Albums!.Count == 0);
|
||||
if (folderId.HasValue)
|
||||
query = query.Where(a => a.FolderId == folderId.Value);
|
||||
if (uploadedBy.HasValue)
|
||||
query = query.Where(a => a.UploadedBy == uploadedBy.Value);
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
query = query.Where(a => EF.Functions.ILike(a.OriginalFilename, $"%{search}%"));
|
||||
|
||||
// Apply visibility filter
|
||||
query = accessLevel switch {
|
||||
EAccessLevel.Admin => query,
|
||||
EAccessLevel.Curator => query.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
|
||||
EAccessLevel.Maintainer => query.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
a.Visibility == EVisibility.Protected ||
|
||||
a.UploadedBy == userId ||
|
||||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
|
||||
context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId && pm.PersonId == al.PersonOwnerId.Value))
|
||||
)),
|
||||
_ => query.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(a.Visibility == EVisibility.Private && userId.HasValue && a.UploadedBy == userId)
|
||||
))
|
||||
};
|
||||
|
||||
total = query.Count();
|
||||
|
||||
@@ -155,6 +225,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
.ToList();
|
||||
|
||||
var assets = context.Assets
|
||||
.AsSplitQuery()
|
||||
.Where(a => shuffledIds.Contains(a.Id))
|
||||
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner)
|
||||
.ToList();
|
||||
@@ -162,7 +233,9 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
return shuffledIds.Select(id => assets.First(a => a.Id == id)).ToList();
|
||||
}
|
||||
|
||||
IQueryable<Asset> dataQuery = query.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner);
|
||||
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
|
||||
@@ -192,4 +265,149 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
.Take(count)
|
||||
.Select(a => a.OriginalPath)
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true) {
|
||||
logger.LogTrace("GetFolderGroups: userId={UserId}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", userId, accessLevel, unlinkedOnly);
|
||||
|
||||
var query = context.Assets
|
||||
.Where(a => a.DeletedAt == null);
|
||||
|
||||
if (unlinkedOnly)
|
||||
query = query.Where(a => a.Albums!.Count == 0);
|
||||
|
||||
if (accessLevel < EAccessLevel.Curator) {
|
||||
if (accessLevel == EAccessLevel.Maintainer && userId.HasValue) {
|
||||
var count = query.Count(a => a.UploadedBy == userId);
|
||||
return [new Butter.Dtos.Asset.AssetGroupDto {
|
||||
FolderId = null,
|
||||
Name = "My Uploads",
|
||||
AssetCount = count
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
var groups = query
|
||||
.GroupBy(a => a.FolderId)
|
||||
.Select(g => new {
|
||||
FolderId = g.Key,
|
||||
AssetCount = g.Count()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var folderNames = context.Folders
|
||||
.Where(f => groups.Select(g => g.FolderId).Contains(f.Id))
|
||||
.ToDictionary(f => f.Id, f => f.BasePath);
|
||||
|
||||
var result = groups
|
||||
.Where(g => g.AssetCount > 0)
|
||||
.Select(g => new Butter.Dtos.Asset.AssetGroupDto {
|
||||
FolderId = g.FolderId,
|
||||
Name = g.FolderId.HasValue && folderNames.TryGetValue(g.FolderId.Value, out var name) ? name : "No Folder",
|
||||
AssetCount = g.AssetCount
|
||||
})
|
||||
.OrderByDescending(g => g.AssetCount)
|
||||
.ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Butter.Dtos.Asset.AssetBrowseResultDto? BrowseAssets(Guid folderId, string currentPath, int page, int pageSize, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly = true) {
|
||||
var folder = context.Folders.Find(folderId);
|
||||
if (folder == null)
|
||||
return null;
|
||||
|
||||
var basePath = folder.BasePath.TrimEnd('/') + "/";
|
||||
var prefix = string.IsNullOrEmpty(currentPath) ? basePath : basePath + currentPath.TrimEnd('/') + "/";
|
||||
var likePattern = prefix + "%";
|
||||
|
||||
var baseQuery = context.Assets
|
||||
.Where(a => a.FolderId == folderId)
|
||||
.Where(a => a.DeletedAt == null);
|
||||
|
||||
if (unlinkedOnly)
|
||||
baseQuery = baseQuery.Where(a => a.Albums!.Count == 0);
|
||||
|
||||
baseQuery = accessLevel switch {
|
||||
EAccessLevel.Admin => baseQuery,
|
||||
EAccessLevel.Curator => baseQuery.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
|
||||
EAccessLevel.Maintainer => baseQuery.Where(a => a.UploadedBy == userId),
|
||||
_ => baseQuery.Where(a => false)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
baseQuery = baseQuery.Where(a => EF.Functions.ILike(a.OriginalFilename, $"%{search}%"));
|
||||
|
||||
var dirs = GetDirectoryNames(prefix, prefix.Length, folderId, userId, accessLevel, search, unlinkedOnly);
|
||||
|
||||
var levelQuery = baseQuery
|
||||
.Where(a => EF.Functions.Like(a.OriginalPath, likePattern))
|
||||
.Where(a => !EF.Functions.Like(a.OriginalPath, prefix + "%/%"));
|
||||
|
||||
var totalAtLevel = levelQuery.Count();
|
||||
|
||||
var assetEntities = levelQuery
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums!).ThenInclude(al => al.PersonOwner)
|
||||
.OrderBy(a => a.OriginalFilename)
|
||||
.Skip(page * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
var assetDtos = assetEntities.Select(a => a.ToAssetPreviewDto(accessLevel, userId)).ToList();
|
||||
|
||||
return new Butter.Dtos.Asset.AssetBrowseResultDto {
|
||||
Directories = dirs,
|
||||
Assets = assetDtos,
|
||||
CurrentPath = currentPath,
|
||||
TotalAssetCount = totalAtLevel
|
||||
};
|
||||
}
|
||||
|
||||
private List<Butter.Dtos.Asset.DirectoryEntryDto> GetDirectoryNames(string prefix, int prefixLen, Guid folderId, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly) {
|
||||
var sql = new System.Text.StringBuilder();
|
||||
sql.Append("SELECT DISTINCT SPLIT_PART(SUBSTRING(a.\"OriginalPath\", LENGTH({0}) + 1), '/', 1) AS \"Name\" ");
|
||||
sql.Append("FROM \"Assets\" a ");
|
||||
if (unlinkedOnly)
|
||||
sql.Append("LEFT JOIN \"AlbumAsset\" aa ON a.\"Id\" = aa.\"AssetsId\" ");
|
||||
sql.Append("WHERE a.\"FolderId\" = {1} ");
|
||||
sql.Append("AND a.\"DeletedAt\" IS NULL ");
|
||||
if (unlinkedOnly)
|
||||
sql.Append("AND aa.\"AssetsId\" IS NULL ");
|
||||
sql.Append("AND a.\"OriginalPath\" LIKE {2} ");
|
||||
sql.Append("AND a.\"OriginalPath\" LIKE {3} ");
|
||||
sql.Append("AND LENGTH(a.\"OriginalPath\") > LENGTH({0}) ");
|
||||
|
||||
var parms = new List<object> { prefix, folderId, prefix + "%", prefix + "%/%" };
|
||||
int p = 4;
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Curator:
|
||||
sql.Append($"AND (a.\"DeletedAt\" IS NULL OR a.\"UploadedBy\" = {{{p}}}) ");
|
||||
parms.Add(userId ?? Guid.Empty);
|
||||
p++;
|
||||
break;
|
||||
case EAccessLevel.Maintainer:
|
||||
sql.Append($"AND a.\"UploadedBy\" = {{{p}}} ");
|
||||
parms.Add(userId ?? Guid.Empty);
|
||||
p++;
|
||||
break;
|
||||
case < EAccessLevel.Curator:
|
||||
sql.Append("AND FALSE ");
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(search)) {
|
||||
sql.Append($"AND a.\"OriginalFilename\" ILIKE '%' || {{{p}}} || '%' ");
|
||||
parms.Add(search!);
|
||||
}
|
||||
|
||||
sql.Append("ORDER BY \"Name\"");
|
||||
|
||||
return context.Database
|
||||
.SqlQueryRaw<Butter.Dtos.Asset.DirectoryEntryDto>(sql.ToString(), parms.ToArray())
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
@@ -39,11 +40,13 @@ public interface IAlbumRepository : IDisposable {
|
||||
public Album? Find(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds albums by the owner's ID.
|
||||
/// Finds an album by its ID with assets filtered by the requesting user's access level.
|
||||
/// </summary>
|
||||
/// <param name="ownerId">The ID of the owner.</param>
|
||||
/// <returns>A collection of albums owned by the specified owner.</returns>
|
||||
public IEnumerable<Album> FindByOwner(Guid ownerId);
|
||||
/// <param name="id">The ID of the album.</param>
|
||||
/// <param name="userId">The requesting user's ID for visibility-scoped asset filtering.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>The album if found; otherwise, null.</returns>
|
||||
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel);
|
||||
|
||||
/// <summary>
|
||||
/// Finds albums by the person's ID.
|
||||
@@ -67,6 +70,14 @@ public interface IAlbumRepository : IDisposable {
|
||||
/// <returns>A collection of albums created or updated within the specified date range.</returns>
|
||||
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to);
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple source albums into a destination album.
|
||||
/// All assets from source albums are moved to the destination, then source albums are hard-deleted.
|
||||
/// </summary>
|
||||
/// <param name="destId">The ID of the destination album.</param>
|
||||
/// <param name="sourceIds">The IDs of the source albums to merge.</param>
|
||||
public void MergeAlbums(Guid destId, List<Guid> sourceIds);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an album from the database.
|
||||
/// </summary>
|
||||
@@ -78,13 +89,14 @@ public interface IAlbumRepository : IDisposable {
|
||||
/// </summary>
|
||||
/// <param name="query">The search query string to filter by title.</param>
|
||||
/// <param name="page">The page number for pagination (default is 0).</param>
|
||||
/// <param name="pageSize">The number of results per page (default is 150).</param>
|
||||
/// <param name="pageSize">The number of results per page (default is <see cref="PagedParametersDto.MaxPageSize"/>).</param>
|
||||
/// <param name="sortBy">The field to sort by (e.g. "name", "created", "updated", "assets", "person"). When <c>null</c>, defaults to created descending.</param>
|
||||
/// <param name="sortAsc">Whether to sort ascending. Default is <c>false</c> (descending).</param>
|
||||
/// <param name="unassigned">When <c>true</c>, only return albums with no person assigned.</param>
|
||||
/// <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>
|
||||
/// <returns>A list of album previews matching the search query.</returns>
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
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);
|
||||
|
||||
}
|
||||
@@ -14,6 +14,23 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <returns>Null if not found, otherwise the requested asset.</returns>
|
||||
public Asset? Find(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an asset by its ID, respecting the requesting user's access level.
|
||||
/// Returns null if the asset does not exist or is not visible to the user.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the asset.</param>
|
||||
/// <param name="userId">The requesting user's ID.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>The asset if found and visible; otherwise null.</returns>
|
||||
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an asset by its ID with its album navigation properties loaded.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the asset.</param>
|
||||
/// <returns>Null if not found, otherwise the requested asset with albums.</returns>
|
||||
public Asset? FindWithAlbums(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a set of assets by their IDs.
|
||||
/// </summary>
|
||||
@@ -22,11 +39,11 @@ public interface IAssetRepository : IDisposable {
|
||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all assets owned by the user.
|
||||
/// Finds all assets uploaded by the user.
|
||||
/// </summary>
|
||||
/// <param name="ownerId">The ID of the owner.</param>
|
||||
/// <returns>A collection of assets owned by the specified user.</returns>
|
||||
public IEnumerable<Asset> FindByOwner(Guid ownerId);
|
||||
/// <param name="uploaderId">The ID of the uploader.</param>
|
||||
/// <returns>A collection of assets uploaded by the specified user.</returns>
|
||||
public IEnumerable<Asset> FindByUploader(Guid uploaderId);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all assets created or updated between the given dates.
|
||||
@@ -160,7 +177,7 @@ public interface IAssetRepository : IDisposable {
|
||||
List<string> GetRandomPathsByFolder(Guid folderId, int count);
|
||||
|
||||
/// <summary>
|
||||
/// Queries assets with optional type filter, date range, and random ordering.
|
||||
/// Queries assets with optional type filter, date range, random ordering, and visibility scoping.
|
||||
/// </summary>
|
||||
/// <param name="type">Optional asset type to filter by.</param>
|
||||
/// <param name="from">Optional start date for the date range filter.</param>
|
||||
@@ -169,7 +186,39 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="seed">An optional seed for deterministic random ordering across paginated requests.</param>
|
||||
/// <param name="pageNumber">The zero-based page number.</param>
|
||||
/// <param name="pageSize">The number of items per page.</param>
|
||||
/// <param name="total">The total count of matching assets.</param>
|
||||
/// <param name="total">The total count of matching assets (after visibility filtering).</param>
|
||||
/// <param name="userId">The requesting user's ID for visibility filtering.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level for visibility filtering.</param>
|
||||
/// <param name="unlinked">If true, filters for assets not assigned to any album.</param>
|
||||
/// <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);
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
|
||||
/// Admins and curators receive all folders with their unlinked asset counts. Maintainers receive a single
|
||||
/// "My Uploads" group with their own unlinked upload count.
|
||||
/// </summary>
|
||||
/// <param name="userId">The requesting user's ID.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="unlinkedOnly">If true (default), only counts assets not assigned to any album.</param>
|
||||
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
|
||||
List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true);
|
||||
|
||||
/// <summary>
|
||||
/// Browses assets at a specific directory level within a folder, returning immediate subdirectories
|
||||
/// and the assets located directly at that level with pagination.
|
||||
/// </summary>
|
||||
/// <param name="folderId">The root folder ID to browse within.</param>
|
||||
/// <param name="currentPath">The current path relative to the folder root (empty string for root).</param>
|
||||
/// <param name="page">The zero-based page number for asset pagination.</param>
|
||||
/// <param name="pageSize">The number of assets per page.</param>
|
||||
/// <param name="userId">The requesting user's ID for visibility filtering.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
|
||||
/// <param name="unlinkedOnly">If true (default), only includes assets not assigned to any album.</param>
|
||||
/// <returns>A browse result containing subdirectories and a page of assets at the current level.</returns>
|
||||
Butter.Dtos.Asset.AssetBrowseResultDto? BrowseAssets(Guid folderId, string currentPath, int page, int pageSize, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly = true);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Types;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -11,22 +12,25 @@ public interface IMediaRepository {
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the media.</param>
|
||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||
public MediaDto? GetOriginalData(Guid id, Guid? userId);
|
||||
public MediaDto? GetOriginalData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the thumbnail media data.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the media.</param>
|
||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||
public MediaDto? GetThumbData(Guid id, Guid? userId);
|
||||
public MediaDto? GetThumbData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the preview media data.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the media.</param>
|
||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||
public MediaDto? GetPreviewData(Guid id, Guid? userId);
|
||||
public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.Person;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for person repository operations.
|
||||
@@ -27,6 +25,22 @@ public interface IPersonRepository : IDisposable {
|
||||
/// <returns>The person if found; otherwise, null.</returns>
|
||||
Person? Find(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a person by their ID with albums and assets filtered by the requesting user's access level.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the person.</param>
|
||||
/// <param name="userId">The requesting user's ID for visibility-scoped filtering.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="albumSearch">Optional search term to filter albums by title.</param>
|
||||
/// <param name="albumSortBy">Optional field to sort albums by ("name", "created", "updated", "assets").</param>
|
||||
/// <param name="albumSortAsc">Whether to sort albums ascending.</param>
|
||||
/// <param name="albumPage">Zero-based page number for album pagination.</param>
|
||||
/// <param name="albumPageSize">Number of albums per page (default 150, max 250).</param>
|
||||
/// <returns>The person if found; otherwise, null.</returns>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a person by their name (case-sensitive).
|
||||
/// </summary>
|
||||
@@ -75,9 +89,33 @@ public interface IPersonRepository : IDisposable {
|
||||
/// <returns>A collection of matching people.</returns>
|
||||
IEnumerable<Person> FindBulk(IEnumerable<Guid> ids);
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple source people into a destination person.
|
||||
/// Albums, faces, and maintainers are reassigned to the destination, then source people are hard-deleted.
|
||||
/// </summary>
|
||||
/// <param name="destId">The ID of the destination person.</param>
|
||||
/// <param name="sourceIds">The IDs of the source people to merge.</param>
|
||||
void MergePeople(Guid destId, List<Guid> sourceIds);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a person from the database.
|
||||
/// </summary>
|
||||
/// <param name="person">The person to remove.</param>
|
||||
void Remove(Person person);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a user is a maintainer of a specific person.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID to check.</param>
|
||||
/// <param name="personId">The person ID to check.</param>
|
||||
/// <returns><see langword="true"/> if the user is a maintainer of the person.</returns>
|
||||
bool IsMaintainerOf(Guid userId, Guid personId);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the list of user IDs that maintain this person.
|
||||
/// Replaces any existing maintainer assignments.
|
||||
/// </summary>
|
||||
/// <param name="personId">The person ID.</param>
|
||||
/// <param name="userIds">The user IDs to assign as maintainers.</param>
|
||||
void SetMaintainers(Guid personId, IEnumerable<Guid> userIds);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,17 @@ public interface IUserRepository : IDisposable {
|
||||
User? FindByUsername(string username);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a user from the database.
|
||||
/// Get the user with their maintained persons eagerly loaded.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
void Delete(User user);
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <returns>Returns the user with maintained persons if found.</returns>
|
||||
User? FindWithMaintainedPersons(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the list of person (cosplayer) IDs that a user maintains.
|
||||
/// Replaces any existing maintainer assignments.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID.</param>
|
||||
/// <param name="personIds">The person IDs to assign.</param>
|
||||
void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,59 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class MediaRepository(LactoseDbContext context) : IMediaRepository {
|
||||
public class MediaRepository(LactoseDbContext context, IPersonRepository personRepository) : IMediaRepository {
|
||||
/// <summary>
|
||||
/// Trys to get the asset with the given ID, and checks if it is shared with the given user or public.
|
||||
/// Trys to get the asset with the given ID, and checks visibility for the requesting user.
|
||||
/// Public assets are accessible to all; Protected assets require authentication;
|
||||
/// Private assets require the user to be the uploader, a curator+, or a maintainer
|
||||
/// of the cosplayer whose album contains the asset.
|
||||
/// </summary>
|
||||
/// <param name="id">Asset ID</param>
|
||||
/// <param name="userId">UserID we are trying to get this as</param>
|
||||
/// <param name="accessLevel">The requesting user's access level</param>
|
||||
/// <returns></returns>
|
||||
Asset? GetAssetAsUserId(Guid id, Guid? userId) {
|
||||
// Should be equivalent to:
|
||||
// media.IsPubliclyShared && media.SharedWith.Any(user => user.Id == userId)
|
||||
return context.Assets.Include(asset => asset.SharedWith)
|
||||
.FirstOrDefault(asset => asset.Id == id && (
|
||||
asset.IsPubliclyShared ||
|
||||
asset.SharedWith!.Any(user => user.Id == userId)
|
||||
));
|
||||
Asset? GetVisibleAsset(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
var query = context.Assets.Where(asset => asset.Id == id);
|
||||
|
||||
if (accessLevel >= EAccessLevel.Admin)
|
||||
return query.FirstOrDefault();
|
||||
|
||||
if (accessLevel >= EAccessLevel.Curator)
|
||||
return query.FirstOrDefault(asset =>
|
||||
asset.DeletedAt == null || asset.UploadedBy == userId);
|
||||
|
||||
if (accessLevel == EAccessLevel.Maintainer && userId.HasValue) {
|
||||
var asset = query
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums)
|
||||
.FirstOrDefault();
|
||||
if (asset == null || asset.DeletedAt != null) return null;
|
||||
if (asset.Visibility == EVisibility.Public) return asset;
|
||||
if (asset.Visibility == EVisibility.Protected) return asset;
|
||||
if (asset.UploadedBy == userId) return asset;
|
||||
if (asset.Albums != null && asset.Albums.Any(a =>
|
||||
a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(userId.Value, a.PersonOwnerId.Value)))
|
||||
return asset;
|
||||
return null;
|
||||
}
|
||||
|
||||
return query.FirstOrDefault(asset =>
|
||||
asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && userId.HasValue && asset.UploadedBy == userId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MediaDto? GetOriginalData(Guid id, Guid? userId) {
|
||||
|
||||
var media = GetAssetAsUserId(id, userId);
|
||||
public MediaDto? GetOriginalData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||
|
||||
if (media == null) return null;
|
||||
|
||||
@@ -37,12 +65,12 @@ public class MediaRepository(LactoseDbContext context) : IMediaRepository {
|
||||
|
||||
//TODO: for Thumbnails the MimeType should be the correct one (depending on which format is used for thumbnails)
|
||||
/// <inheritdoc />
|
||||
public MediaDto? GetThumbData(Guid id, Guid? userId) {
|
||||
var media = GetAssetAsUserId(id, userId);
|
||||
public MediaDto? GetThumbData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||
|
||||
if(media == null) return null;
|
||||
|
||||
if (media.ThumbnailPath == String.Empty) return null;
|
||||
if (string.IsNullOrEmpty(media.ThumbnailPath)) return null;
|
||||
|
||||
return new MediaDto {
|
||||
MimeType = "image/webp",
|
||||
@@ -52,12 +80,12 @@ public class MediaRepository(LactoseDbContext context) : IMediaRepository {
|
||||
|
||||
//TODO: for Previews the MimeType should be the correct one (depending on which format is used for previews and if the asset is a video)
|
||||
/// <inheritdoc />
|
||||
public MediaDto? GetPreviewData(Guid id, Guid? userId) {
|
||||
var media = GetAssetAsUserId(id, userId);
|
||||
public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||
|
||||
if(media == null) return null;
|
||||
|
||||
if (media.PreviewPath == String.Empty) return null;
|
||||
if (string.IsNullOrEmpty(media.PreviewPath)) return null;
|
||||
|
||||
return new MediaDto {
|
||||
MimeType = "image/webp",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.Person;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
@@ -15,32 +17,135 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
|
||||
/// <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)!.ThenInclude(a => a.SharedWith)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.UserOwner)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
|
||||
.Include(p => p.Maintainers)
|
||||
.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)
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == id);
|
||||
|
||||
if (person == null) return null;
|
||||
|
||||
// R4: Gate person visibility
|
||||
bool canSeePerson = accessLevel switch {
|
||||
< EAccessLevel.Maintainer => person.Visibility <= EVisibility.Protected,
|
||||
EAccessLevel.Maintainer when userId.HasValue => person.Visibility <= EVisibility.Protected
|
||||
|| context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id),
|
||||
EAccessLevel.Maintainer => person.Visibility <= EVisibility.Protected,
|
||||
_ => true
|
||||
};
|
||||
|
||||
if (!canSeePerson) return null;
|
||||
|
||||
if (person.Albums == null) return person;
|
||||
|
||||
// Filter albums and assets by visibility
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Admin:
|
||||
case EAccessLevel.Curator:
|
||||
break;
|
||||
|
||||
case EAccessLevel.Maintainer when userId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id):
|
||||
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();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply album search
|
||||
if (!string.IsNullOrEmpty(albumSearch)) {
|
||||
person.Albums = person.Albums
|
||||
.Where(a => a.Title.Contains(albumSearch, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// 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
|
||||
.Skip(albumPage * albumPageSize)
|
||||
.Take(albumPageSize)
|
||||
.ToList();
|
||||
|
||||
return person;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Person> GetAll() =>
|
||||
context.People.Include(p => p.Albums);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel) {
|
||||
if (accessLevel != EAccessLevel.User)
|
||||
return GetAll();
|
||||
public IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel) =>
|
||||
accessLevel switch {
|
||||
// Admin and curator.
|
||||
>= EAccessLevel.Curator => GetAll(),
|
||||
|
||||
return context.People
|
||||
.Include(p => p.Albums)
|
||||
.Where(p => p.Albums!.Any(a =>
|
||||
a.Assets!.Any(asset => asset.DeletedAt == null && (
|
||||
asset.IsPubliclyShared ||
|
||||
asset.OwnerId == userId ||
|
||||
asset.SharedWith!.Any(u => u.Id == userId)
|
||||
))
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
EAccessLevel.Maintainer => context.People
|
||||
.Include(p => p.Albums)
|
||||
.Where(p => p.Visibility <= EVisibility.Protected
|
||||
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id))
|
||||
.ToList(),
|
||||
|
||||
EAccessLevel.User => context.People
|
||||
.Include(p => p.Albums)
|
||||
.Where(p => p.Visibility <= EVisibility.Protected)
|
||||
.ToList(),
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? FindByName(string name) =>
|
||||
@@ -64,6 +169,18 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
peopleQuery = peopleQuery.Where(p => EF.Functions.ILike(p.Name, $"%{query}%"));
|
||||
|
||||
// Apply visibility filter
|
||||
peopleQuery = accessLevel switch {
|
||||
EAccessLevel.Admin or EAccessLevel.Curator => peopleQuery,
|
||||
|
||||
EAccessLevel.Maintainer => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected
|
||||
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id)),
|
||||
|
||||
EAccessLevel.User => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected),
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
|
||||
};
|
||||
|
||||
// Apply sorting — use subquery counts for "albums"
|
||||
IOrderedQueryable<Person> ordered = sortBy?.ToLower() switch
|
||||
{
|
||||
@@ -74,7 +191,6 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
_ => sortAsc ? peopleQuery.OrderBy(p => p.Name) : peopleQuery.OrderByDescending(p => p.Name),
|
||||
};
|
||||
|
||||
// Two-step pagination: first get the IDs in the correct order
|
||||
var pageOffset = page * pageSize;
|
||||
var personIds = ordered
|
||||
.Skip(pageOffset)
|
||||
@@ -85,46 +201,65 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
if (personIds.Count == 0)
|
||||
return [];
|
||||
|
||||
// Load with includes for client-side visibility filtering
|
||||
var pagedPeople = context.People
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)!.ThenInclude(a => a.SharedWith)
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.Albums)
|
||||
.Where(p => personIds.Contains(p.Id))
|
||||
.ToList();
|
||||
|
||||
// Filter out people with no visible content for regular users
|
||||
if (accessLevel == EAccessLevel.User)
|
||||
pagedPeople = pagedPeople.Where(p =>
|
||||
p.Albums != null && p.Albums.Any(a =>
|
||||
a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && (
|
||||
asset.IsPubliclyShared ||
|
||||
asset.OwnerId == userId ||
|
||||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
|
||||
)))
|
||||
).ToList();
|
||||
var dtos = pagedPeople.Select(p => p.ToPersonPreviewDto(accessLevel)).ToList();
|
||||
|
||||
// Project to DTOs
|
||||
var dtos = pagedPeople.Select(p => new PersonPreviewDto {
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
ProfileAssetId = p.ProfileAssetId,
|
||||
ProfileCropX = p.ProfileCropX,
|
||||
ProfileCropY = p.ProfileCropY,
|
||||
ProfileCropZoom = p.ProfileCropZoom,
|
||||
TotalAlbums = accessLevel == EAccessLevel.User
|
||||
? p.Albums?.Count(a =>
|
||||
a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && (
|
||||
asset.IsPubliclyShared ||
|
||||
asset.OwnerId == userId ||
|
||||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
|
||||
))) ?? 0
|
||||
: p.Albums?.Count ?? 0
|
||||
}).ToList();
|
||||
|
||||
// Reorder to match the paginated order
|
||||
var orderMap = personIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsMaintainerOf(Guid userId, Guid personId) =>
|
||||
context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == personId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetMaintainers(Guid personId, IEnumerable<Guid> userIds) {
|
||||
var existing = context.PersonMaintainers.Where(pm => pm.PersonId == personId);
|
||||
context.PersonMaintainers.RemoveRange(existing);
|
||||
|
||||
foreach (var userId in userIds) {
|
||||
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = personId, UserId = userId });
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MergePeople(Guid destId, List<Guid> sourceIds) {
|
||||
var dest = context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == destId);
|
||||
if (dest == null) return;
|
||||
|
||||
// Reassign albums
|
||||
var albums = context.Albums.Where(a => a.PersonOwnerId != null && sourceIds.Contains(a.PersonOwnerId.Value)).ToList();
|
||||
foreach (var album in albums)
|
||||
album.PersonOwnerId = destId;
|
||||
|
||||
// Reassign faces
|
||||
var faces = context.Faces.Where(f => f.PersonId.HasValue && sourceIds.Contains(f.PersonId.Value)).ToList();
|
||||
foreach (var face in faces)
|
||||
face.PersonId = destId;
|
||||
|
||||
// Reassign maintainers
|
||||
var existingMaintainerIds = dest.Maintainers?.Select(m => m.Id).ToHashSet() ?? [];
|
||||
var sourceMaintainers = context.PersonMaintainers
|
||||
.Where(pm => sourceIds.Contains(pm.PersonId))
|
||||
.ToList();
|
||||
foreach (var pm in sourceMaintainers) {
|
||||
if (!existingMaintainerIds.Contains(pm.UserId))
|
||||
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = destId, UserId = pm.UserId });
|
||||
}
|
||||
|
||||
// Hard-delete source people
|
||||
var people = context.People.Where(p => sourceIds.Contains(p.Id)).ToList();
|
||||
context.People.RemoveRange(people);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => context.Dispose();
|
||||
}
|
||||
|
||||
@@ -52,8 +52,9 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti
|
||||
.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.IsPubliclyShared);
|
||||
dto.PrivateAssets = context.Assets.Count(a => a.DeletedAt == null && !a.IsPubliclyShared);
|
||||
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);
|
||||
@@ -95,7 +96,7 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti
|
||||
|
||||
dto.AlbumsMissingCover = context.Albums.Count(a => a.CoverAssetId == null);
|
||||
|
||||
dto.CosplayersMissingCover = 0;
|
||||
dto.CosplayersMissingProfile = context.People.Count(p => p.ProfileAssetId == null);
|
||||
|
||||
dto.TopTags = (
|
||||
from at in context.Set<Dictionary<string, object>>("AssetTag")
|
||||
|
||||
@@ -9,7 +9,7 @@ public class UserRepository(LactoseDbContext context) : IUserRepository {
|
||||
public void Dispose() => context.Dispose();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<User> GetAll() => context.Users.AsEnumerable();
|
||||
public IEnumerable<User> GetAll() => context.Users.Include(u => u.MaintainedPersons).AsEnumerable();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Insert(User user) => context.Users.Add(user);
|
||||
@@ -27,5 +27,16 @@ public class UserRepository(LactoseDbContext context) : IUserRepository {
|
||||
public User? FindByUsername(string username) => context.Users.FirstOrDefault(u => u.Username == username);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Delete(User user) => context.Users.Remove(user);
|
||||
public User? FindWithMaintainedPersons(Guid id) =>
|
||||
context.Users.Include(u => u.MaintainedPersons).FirstOrDefault(u => u.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds) {
|
||||
var existing = context.PersonMaintainers.Where(pm => pm.UserId == userId);
|
||||
context.PersonMaintainers.RemoveRange(existing);
|
||||
|
||||
foreach (var personId in personIds) {
|
||||
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = personId, UserId = userId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1316
-75
File diff suppressed because it is too large
Load Diff
@@ -21,5 +21,31 @@
|
||||
},
|
||||
"SignKey": {
|
||||
"Key": "32_CHARACTERS_KEY_IS_REQUIRED_TO_WORK"
|
||||
},
|
||||
"RateLimiting": {
|
||||
"Global": {
|
||||
"PermitLimit": 100,
|
||||
"WindowSeconds": 60
|
||||
},
|
||||
"Auth": {
|
||||
"PermitLimit": 10,
|
||||
"WindowSeconds": 60
|
||||
},
|
||||
"MediaOriginal": {
|
||||
"PermitLimit": 50,
|
||||
"WindowSeconds": 60
|
||||
},
|
||||
"MediaThumb": {
|
||||
"PermitLimit": 1000,
|
||||
"WindowSeconds": 60
|
||||
},
|
||||
"MediaPreview": {
|
||||
"PermitLimit": 250,
|
||||
"WindowSeconds": 60
|
||||
},
|
||||
"Jobs": {
|
||||
"PermitLimit": 100,
|
||||
"WindowSeconds": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,9 @@
|
||||
[Parameter]
|
||||
public EventCallback<Guid> OnDeleted { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> OnDeleteRequested { get; set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!_patternExpanded)
|
||||
@@ -153,8 +156,7 @@
|
||||
|
||||
async Task OnDelete()
|
||||
{
|
||||
await FoldersService.RemoveFolderAsync(Folder.Id);
|
||||
await OnDeleted.InvokeAsync(Folder.Id);
|
||||
await OnDeleteRequested.InvokeAsync(Folder.Id);
|
||||
}
|
||||
|
||||
async Task OnToggleActive()
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
[Parameter] public IReadOnlyList<JobStatusDto>? Children { get; set; }
|
||||
|
||||
static readonly Dictionary<EJobStatus, string> _statusColors = new() {
|
||||
[EJobStatus.Queued] = "primary",
|
||||
[EJobStatus.Queued] = "secondary",
|
||||
[EJobStatus.Running] = "primary",
|
||||
[EJobStatus.Waiting] = "info",
|
||||
[EJobStatus.Completed] = "success",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<li><a class="dropdown-item" @onclick="OnStatsClick"><i class="bi bi-bar-chart-fill"></i> Statistics</a></li>
|
||||
}
|
||||
@if(Admin) {
|
||||
<li><a class="dropdown-item" @onclick="OnMaintenanceClick"><i class="bi bi-tools"></i> Maintenance</a></li>
|
||||
<li><a class="dropdown-item" @onclick="OnUsersClick"><i class="bi bi-people-fill"></i> Users</a></li>
|
||||
<li><a class="dropdown-item" @onclick="OnSettingsClick"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
|
||||
<li><a class="dropdown-item" @onclick="OnJobsClick"><i class="bi bi-kanban-fill"></i> Jobs</a></li>
|
||||
@@ -63,8 +64,8 @@
|
||||
|
||||
@code{
|
||||
bool LoggedIn => loginService.IsLoggedIn;
|
||||
bool Admin => loginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
|
||||
bool AdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
||||
bool Admin => loginService.IsAdmin;
|
||||
bool AdminOrCurator => loginService.IsAdminOrCurator;
|
||||
|
||||
protected override void OnInitialized() {
|
||||
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
|
||||
@@ -81,6 +82,7 @@
|
||||
|
||||
void OnJobsClick() => navigation.NavigateTo("/jobs");
|
||||
|
||||
void OnMaintenanceClick() => navigation.NavigateTo("/maintenance");
|
||||
void OnStatsClick() => navigation.NavigateTo("/stats");
|
||||
|
||||
void OnUsersClick() => navigation.NavigateTo("/users");
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
@page "/Users"
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Dtos.User
|
||||
@using Butter.Types
|
||||
|
||||
@implements IDisposable
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject LoginService LoginService
|
||||
@inject UserService UserService
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject PersonService PersonService
|
||||
|
||||
<PageTitle>Users</PageTitle>
|
||||
|
||||
@@ -81,7 +84,7 @@
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="user-initials">@(user.Username?.Length > 0 ? user.Username[..1].ToUpper() : "?")</div>
|
||||
<div>
|
||||
<span>@user.Username</span>
|
||||
<a href="/User/@user.Id" class="text-reset text-decoration-none">@user.Username</a>
|
||||
@if (isSelf) { <span class="badge bg-info ms-1">You</span> }
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +125,7 @@
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<div class="user-initials">@(user.Username?.Length > 0 ? user.Username[..1].ToUpper() : "?")</div>
|
||||
<div class="flex-grow-1">
|
||||
<strong>@user.Username</strong>
|
||||
<a href="/User/@user.Id" class="text-reset text-decoration-none fw-bold">@user.Username</a>
|
||||
@if (isSelf) { <span class="badge bg-info ms-1">You</span> }
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => OpenEditModal(user)">
|
||||
@@ -175,9 +178,9 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Level</label>
|
||||
<select class="form-select" @bind="_createModel.AccessLevel">
|
||||
<option value="@EAccessLevel.User">User</option>
|
||||
<option value="@EAccessLevel.Curator">Curator</option>
|
||||
<option value="@EAccessLevel.Admin">Admin</option>
|
||||
@foreach (var level in Enum.GetValues<EAccessLevel>()) {
|
||||
<option value="@level">@level</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</Body>
|
||||
@@ -208,12 +211,36 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Level</label>
|
||||
<select class="form-select" @bind="_editAccessLevel">
|
||||
<option value="@EAccessLevel.User">User</option>
|
||||
<option value="@EAccessLevel.Curator">Curator</option>
|
||||
<option value="@EAccessLevel.Admin">Admin</option>
|
||||
@foreach (var level in Enum.GetValues<EAccessLevel>()) {
|
||||
<option value="@level">@level</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h6>Maintained Cosplayers</h6>
|
||||
<div class="mb-3">
|
||||
<SearchAssignBadges TItem="PersonPreviewDto"
|
||||
Placeholder="Search cosplayers..."
|
||||
SearchQuery="@_personSearchQuery"
|
||||
OnSearch="HandlePersonSearchInput"
|
||||
SearchResults="@_personSearchResults"
|
||||
SelectedItems="@_assignedPersons"
|
||||
OnSelect="SelectPerson"
|
||||
OnRemove="RemoveAssignedPerson">
|
||||
<ItemDisplay Context="person">
|
||||
@person.Name
|
||||
</ItemDisplay>
|
||||
</SearchAssignBadges>
|
||||
|
||||
@if (_maintainedPersonIds.Count > 0)
|
||||
{
|
||||
<div class="mt-1">
|
||||
<small class="text-muted">@_maintainedPersonIds.Count cosplayer@(_maintainedPersonIds.Count != 1 ? "s" : "") selected</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h6>Danger Zone</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@@ -284,6 +311,14 @@
|
||||
</Footer>
|
||||
</ModalFrame>
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
Title="Confirm Deletion"
|
||||
Message="@($"Delete user '{_editUser?.Username}'? This can be undone.")"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecuteDelete"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
|
||||
@code {
|
||||
private List<UserInfoDto>? _users;
|
||||
private bool _isLoading = true;
|
||||
@@ -304,6 +339,7 @@
|
||||
private string? _createError;
|
||||
|
||||
private UserInfoDto? _editUser;
|
||||
bool _showDeleteConfirm;
|
||||
private string _editUsername = "";
|
||||
private string _editEmail = "";
|
||||
private EAccessLevel _editAccessLevel;
|
||||
@@ -317,6 +353,12 @@
|
||||
private bool _showNewPassword;
|
||||
private bool _showConfirmPassword;
|
||||
|
||||
private HashSet<Guid> _maintainedPersonIds = [];
|
||||
private List<PersonPreviewDto> _assignedPersons = [];
|
||||
private string _personSearchQuery = "";
|
||||
private List<PersonPreviewDto>? _personSearchResults;
|
||||
private CancellationTokenSource? _searchCts;
|
||||
|
||||
private IEnumerable<UserInfoDto> _filteredSorted {
|
||||
get {
|
||||
if (_users == null) return [];
|
||||
@@ -350,7 +392,7 @@
|
||||
NavigationManager.NavigateToLogin();
|
||||
return;
|
||||
}
|
||||
if (LoginService.LoggedUser?.AccessLevel != EAccessLevel.Admin) {
|
||||
if (!LoginService.IsAdmin) {
|
||||
NavigationManager.NavigateTo("/");
|
||||
return;
|
||||
}
|
||||
@@ -407,12 +449,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenEditModal(UserInfoDto user) {
|
||||
private async Task OpenEditModal(UserInfoDto user) {
|
||||
_editUser = user;
|
||||
_editUsername = user.Username ?? "";
|
||||
_editEmail = user.Email ?? "";
|
||||
_editAccessLevel = user.AccessLevel;
|
||||
_editError = null;
|
||||
_maintainedPersonIds = [];
|
||||
_assignedPersons = [];
|
||||
_personSearchResults = null;
|
||||
_personSearchQuery = "";
|
||||
|
||||
var fullUser = await UserService.GetUserByIdAsync(user.Id);
|
||||
if (fullUser?.MaintainedPersonIds != null) {
|
||||
_maintainedPersonIds = [.. fullUser.MaintainedPersonIds];
|
||||
foreach (var pid in _maintainedPersonIds) {
|
||||
var person = await PersonService.GetByIdAsync(pid);
|
||||
if (person != null) {
|
||||
_assignedPersons.Add(new PersonPreviewDto { Id = person.Id, Name = person.Name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseEditModal() {
|
||||
@@ -434,7 +491,8 @@
|
||||
Id = _editUser.Id,
|
||||
Username = _editUsername,
|
||||
Email = _editEmail,
|
||||
AccessLevel = _editAccessLevel
|
||||
AccessLevel = _editAccessLevel,
|
||||
MaintainedPersonIds = _maintainedPersonIds.ToList()
|
||||
};
|
||||
var (result, error) = await UserService.UpdateUserAsync(dto);
|
||||
if (result != null) {
|
||||
@@ -463,8 +521,12 @@
|
||||
|
||||
private async Task ConfirmDelete() {
|
||||
if (_editUser == null) return;
|
||||
if (!await JSRuntime.InvokeAsync<bool>("confirm", $"Delete user '{_editUser.Username}'? This can be undone.")) return;
|
||||
_showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteDelete() {
|
||||
if (_editUser == null) return;
|
||||
_showDeleteConfirm = false;
|
||||
var success = await UserService.DeleteUserAsync(_editUser.Id);
|
||||
if (success) {
|
||||
_successMessage = $"User '{_editUser.Username}' deleted.";
|
||||
@@ -499,9 +561,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandlePersonSearchInput(ChangeEventArgs e) {
|
||||
_personSearchQuery = e.Value?.ToString() ?? "";
|
||||
await SearchPersons(_personSearchQuery);
|
||||
}
|
||||
|
||||
private async Task SearchPersons(string query) {
|
||||
_searchCts?.Cancel();
|
||||
_searchCts = new CancellationTokenSource();
|
||||
var token = _searchCts.Token;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query) || query.Length < 2) {
|
||||
_personSearchResults = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var results = await PersonService.GetAllAsync(search: query, pageSize: 250);
|
||||
if (!token.IsCancellationRequested) {
|
||||
_personSearchResults = results;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectPerson(PersonPreviewDto person) {
|
||||
if (_maintainedPersonIds.Add(person.Id)) {
|
||||
_assignedPersons.Add(person);
|
||||
}
|
||||
_personSearchQuery = "";
|
||||
_personSearchResults = null;
|
||||
}
|
||||
|
||||
private void RemoveAssignedPerson(PersonPreviewDto person) {
|
||||
_maintainedPersonIds.Remove(person.Id);
|
||||
_assignedPersons.RemoveAll(p => p.Id == person.Id);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose() {
|
||||
_searchCts?.Cancel();
|
||||
_searchCts?.Dispose();
|
||||
}
|
||||
|
||||
private static string AccessLevelBadge(EAccessLevel level) => level switch {
|
||||
EAccessLevel.Admin => "bg-danger",
|
||||
EAccessLevel.Curator => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
EAccessLevel.Admin => "badge-role-admin",
|
||||
EAccessLevel.Curator => "badge-role-curator",
|
||||
EAccessLevel.Maintainer => "badge-role-maintainer",
|
||||
_ => "badge-role-user"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
@page "/albums/{Id:guid}"
|
||||
@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
|
||||
@@ -17,18 +19,18 @@
|
||||
} else if (album == null) {
|
||||
<EmptyState Variant="danger" Title="Album not found">
|
||||
<p>The album you are looking for does not exist or you do not have access to it.</p>
|
||||
<a href="/albums" class="btn btn-danger">Back to Albums</a>
|
||||
<button class="btn btn-danger" @onclick="GoBack">Back to Albums</button>
|
||||
</EmptyState>
|
||||
} else {
|
||||
<div class="album-detail">
|
||||
<div class="album-detail-header">
|
||||
<a href="/albums" class="btn btn-sm btn-secondary me-2">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-secondary me-2" @onclick="GoBack">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<div class="album-detail-info">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="album-title-text">@album.Name</span>
|
||||
@if (IsAdminOrCurator) {
|
||||
@if (loginService.CanEdit(album?.Person)) {
|
||||
<button class="btn btn-sm album-edit-btn" @onclick="OpenEditForm" title="Edit album">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
@@ -39,6 +41,7 @@
|
||||
<a class="album-cosplayer-link" href="/cosplayer/@album.Person.Value">@album.PersonName</a>
|
||||
}
|
||||
<span class="text-muted small">@album.AssetCount assets</span>
|
||||
<span class="text-muted small">@AssetStatsText</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="album-detail-actions">
|
||||
@@ -52,17 +55,29 @@
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (IsAdminOrCurator) {
|
||||
@if (loginService.CanEdit(album?.Person)) {
|
||||
@* Desktop: inline buttons *@
|
||||
<div class="d-none d-md-flex gap-1 align-items-center">
|
||||
@if (selectMode) {
|
||||
<button class="btn btn-sm btn-danger" @onclick="RemoveSelectedImages" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Remove @selectedImageIds.Count
|
||||
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedImages" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedImageIds.Count
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" @onclick="CancelSelectMode">
|
||||
<button class="btn btn-sm btn-warning" @onclick="UnlinkSelectedImages" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Unlink @selectedImageIds.Count
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAll">
|
||||
@(allSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
|
||||
Done
|
||||
</button>
|
||||
} else {
|
||||
<button class="btn btn-sm btn-success" @onclick="OpenAssetPicker">
|
||||
<i class="bi bi-plus-circle"></i> Add Assets
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" @onclick="EnterSelectMode">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
@@ -79,8 +94,24 @@
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
@if (selectMode) {
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" @onclick="RemoveSelectedImages" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Remove @selectedImageIds.Count
|
||||
<button class="dropdown-item text-danger" @onclick="DeleteSelectedImages" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedImageIds.Count
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item text-warning" @onclick="UnlinkSelectedImages" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Unlink @selectedImageIds.Count
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="ToggleSelectAll">
|
||||
<i class="bi @(allSelected ? "bi-x-square" : "bi-check2-square")"></i> @(allSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
@@ -89,6 +120,11 @@
|
||||
</button>
|
||||
</li>
|
||||
} else {
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenAssetPicker">
|
||||
<i class="bi bi-plus-circle"></i> Add Assets
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="EnterSelectMode">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
@@ -114,9 +150,10 @@
|
||||
@for (int i = 0; i < album.Images.Count; i++) {
|
||||
var assetId = album.Images[i];
|
||||
var preview = album.AssetPreviews.Count > i ? album.AssetPreviews[i] : null;
|
||||
if (!selectMode && preview != null && (preview.DeletedAt != null || (preview.Visibility.HasValue && preview.Visibility.Value == EVisibility.Private))) continue;
|
||||
var index = i;
|
||||
var isSelected = selectedImageIds.Contains(assetId);
|
||||
<div class="@GetTileClass(isSelected)" @key="assetId"
|
||||
<div class="@GetTileClass(isSelected, preview)" @key="assetId"
|
||||
style="aspect-ratio:@GetAspectRatio(preview)"
|
||||
@onclick="() => HandleTileClick(index, assetId)">
|
||||
@if (preview == null || preview.HasThumbnail) {
|
||||
@@ -171,6 +208,30 @@
|
||||
</ImagePreview>
|
||||
}
|
||||
|
||||
@if (showVisibilityModal) {
|
||||
<VisibilityModal Show="true"
|
||||
ItemCount="@selectedImageIds.Count"
|
||||
ItemLabel="asset(s)"
|
||||
Visibility="@selectedVisibility"
|
||||
VisibilityChanged="@((EVisibility v) => selectedVisibility = v)"
|
||||
OnApply="ApplyVisibility"
|
||||
OnClose="CloseVisibilityModal" />
|
||||
}
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
Title="Confirm Deletion"
|
||||
Message="@_deleteConfirmMessage"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecutePendingDelete"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
|
||||
@if (showAssetPicker && album != null) {
|
||||
<AlbumAssetPicker Show="true"
|
||||
OnSelected="OnAssetsAdded"
|
||||
OnClosed="() => showAssetPicker = false" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid Id { get; set; }
|
||||
@@ -179,12 +240,17 @@
|
||||
bool isLoading = true;
|
||||
bool showForm;
|
||||
bool selectMode;
|
||||
bool showVisibilityModal;
|
||||
bool showAssetPicker;
|
||||
bool _showDeleteConfirm;
|
||||
string _deleteConfirmMessage = "";
|
||||
Func<Task>? _pendingDeleteAction;
|
||||
EVisibility selectedVisibility = EVisibility.Public;
|
||||
HashSet<Guid> selectedImageIds = [];
|
||||
Guid? selectedAssetId;
|
||||
int selectedIndex = -1;
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
string viewMode = "masonry";
|
||||
@@ -196,6 +262,34 @@
|
||||
? album.AssetPreviews[selectedIndex]
|
||||
: null;
|
||||
|
||||
bool allSelected => album != null && album.Images.Count > 0 && selectedImageIds.Count == album.Images.Count;
|
||||
|
||||
void ToggleSelectAll() {
|
||||
if (album == null) return;
|
||||
if (allSelected)
|
||||
selectedImageIds.Clear();
|
||||
else
|
||||
selectedImageIds.UnionWith(album.Images);
|
||||
}
|
||||
|
||||
string AssetStatsText {
|
||||
get {
|
||||
if (album?.AssetPreviews == null) return "";
|
||||
var total = album.AssetPreviews.Count;
|
||||
var publicCount = album.AssetPreviews.Count(p => p.Visibility == EVisibility.Public);
|
||||
var protectedCount = album.AssetPreviews.Count(p => p.Visibility == EVisibility.Protected);
|
||||
var privateCount = album.AssetPreviews.Count(p => p.Visibility == EVisibility.Private);
|
||||
var deletedCount = album.AssetPreviews.Count(p => p.DeletedAt != null);
|
||||
var visible = album.AssetPreviews.Count(p => p.DeletedAt == null && p.Visibility != EVisibility.Private);
|
||||
|
||||
if (!selectMode) return loginService.CanEdit(album?.Person) ? $" — {visible} visible" : "";
|
||||
var parts = new List<string> { $"{publicCount} public", $"{protectedCount} protected", $"{privateCount} private" };
|
||||
if (loginService.CanEdit(album?.Person) && deletedCount > 0)
|
||||
parts.Add($"{deletedCount} deleted");
|
||||
return $" — {string.Join(", ", parts)}";
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
@@ -254,12 +348,44 @@
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
async Task GoBack() {
|
||||
await jsRuntime.InvokeVoidAsync("history.back");
|
||||
navigationManager.NavigateTo("/albums");
|
||||
}
|
||||
|
||||
async Task ConfirmDelete() {
|
||||
if (album == null) return;
|
||||
_deleteConfirmMessage = $"Delete album '{album.Name}'? This cannot be undone.";
|
||||
_pendingDeleteAction = ExecuteDeleteAlbum;
|
||||
_showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteDeleteAlbum() {
|
||||
if (album == null) return;
|
||||
_showDeleteConfirm = false;
|
||||
await albumService.DeleteAlbumAsync(album.Id);
|
||||
navigationManager.NavigateTo("/albums");
|
||||
}
|
||||
|
||||
async Task ExecutePendingDelete() {
|
||||
if (_pendingDeleteAction != null)
|
||||
await _pendingDeleteAction();
|
||||
}
|
||||
|
||||
void OpenAssetPicker() {
|
||||
showAssetPicker = true;
|
||||
}
|
||||
|
||||
async Task OnAssetsAdded(List<Guid> selectedIds) {
|
||||
if (album == null) return;
|
||||
var merged = album.Images
|
||||
.Union(selectedIds)
|
||||
.ToList();
|
||||
await albumService.UpdateAlbumAsync(album.Id, new AlbumUpdateDto { Assets = merged });
|
||||
showAssetPicker = false;
|
||||
await LoadAlbum();
|
||||
}
|
||||
|
||||
void EnterSelectMode() {
|
||||
selectMode = true;
|
||||
selectedImageIds.Clear();
|
||||
@@ -284,7 +410,7 @@
|
||||
selectedImageIds.Add(id);
|
||||
}
|
||||
|
||||
async Task RemoveSelectedImages() {
|
||||
async Task UnlinkSelectedImages() {
|
||||
if (album == null || selectedImageIds.Count == 0) return;
|
||||
var remaining = album.Images.Where(id => !selectedImageIds.Contains(id)).ToList();
|
||||
await albumService.UpdateAlbumAsync(album.Id, new AlbumUpdateDto { Assets = remaining });
|
||||
@@ -293,9 +419,59 @@
|
||||
await LoadAlbum();
|
||||
}
|
||||
|
||||
string GetTileClass(bool isSelected) {
|
||||
if (!selectMode) return "masonry-tile";
|
||||
return isSelected ? "masonry-tile masonry-tile-selected" : "masonry-tile";
|
||||
async Task DeleteSelectedImages() {
|
||||
if (album == null || selectedImageIds.Count == 0) return;
|
||||
_deleteConfirmMessage = $"Delete {selectedImageIds.Count} selected asset{(selectedImageIds.Count == 1 ? "" : "s")}? This cannot be undone.";
|
||||
_pendingDeleteAction = ExecuteDeleteSelectedImages;
|
||||
_showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteDeleteSelectedImages() {
|
||||
if (album == null || selectedImageIds.Count == 0) return;
|
||||
_showDeleteConfirm = false;
|
||||
await assetService.BulkDeleteAssetsAsync([.. selectedImageIds]);
|
||||
selectMode = false;
|
||||
selectedImageIds.Clear();
|
||||
await LoadAlbum();
|
||||
}
|
||||
|
||||
void OpenVisibilityModal() {
|
||||
showVisibilityModal = true;
|
||||
selectedVisibility = EVisibility.Public;
|
||||
}
|
||||
|
||||
void CloseVisibilityModal() {
|
||||
showVisibilityModal = false;
|
||||
}
|
||||
|
||||
async Task ApplyVisibility() {
|
||||
if (album == null || selectedImageIds.Count == 0) return;
|
||||
await assetService.BulkUpdateAssetsAsync([.. selectedImageIds], new AssetUpdateDto { Visibility = selectedVisibility });
|
||||
showVisibilityModal = false;
|
||||
selectMode = false;
|
||||
selectedImageIds.Clear();
|
||||
await LoadAlbum();
|
||||
}
|
||||
|
||||
string GetTileClass(bool isSelected, AlbumAssetPreviewDto? preview) {
|
||||
var classes = new List<string> { "masonry-tile" };
|
||||
if (selectMode) {
|
||||
bool hasStateMarker = false;
|
||||
if (loginService.CanEdit(album?.Person) && preview != null) {
|
||||
if (preview.DeletedAt != null) {
|
||||
classes.Add("tile-deleted");
|
||||
hasStateMarker = true;
|
||||
} else if (preview.Visibility.HasValue && preview.Visibility.Value == EVisibility.Private) {
|
||||
classes.Add("border border-danger border-5");
|
||||
hasStateMarker = true;
|
||||
} else if (preview.Visibility.HasValue && preview.Visibility.Value == EVisibility.Protected) {
|
||||
classes.Add("border border-warning border-5");
|
||||
hasStateMarker = true;
|
||||
}
|
||||
}
|
||||
if (isSelected && !hasStateMarker) classes.Add("masonry-tile-selected");
|
||||
}
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
void OpenPreview(int index) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
@@ -142,6 +143,16 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Deleted asset (greyed out, admin/curator only) ── */
|
||||
.masonry-tile.tile-deleted {
|
||||
opacity: 0.4;
|
||||
filter: grayscale(0.8);
|
||||
}
|
||||
|
||||
.masonry-tile.tile-deleted:hover {
|
||||
filter: grayscale(0.6) brightness(1.15);
|
||||
}
|
||||
|
||||
/* ── Select mode ── */
|
||||
.masonry-tile-selected {
|
||||
outline: 3px solid var(--accent);
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
@using Butter.Types
|
||||
@implements IDisposable
|
||||
|
||||
@using Butter.Dtos.Asset
|
||||
@inject AlbumService albumService
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject NavigationManager navigationManager
|
||||
@@ -21,13 +23,26 @@
|
||||
SortAscChanged="@((bool v) => sortAsc = v)"
|
||||
SortOptions="@sortOptions">
|
||||
<ActionButtons>
|
||||
<div class="d-flex gap-2 flex-shrink-0 flex-wrap align-items-center">
|
||||
@if (IsAdminOrCurator) {
|
||||
if (selectMode) {
|
||||
<button class="btn btn-danger btn-sm" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
@if (loginService.CanEditAll) {
|
||||
@* Desktop: inline buttons *@
|
||||
<div class="d-none d-md-flex gap-1 align-items-center">
|
||||
@if (selectMode) {
|
||||
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="CancelSelectMode">
|
||||
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning" @onclick="OpenLinkCosplayerModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Link to Cosplayer
|
||||
</button>
|
||||
<button class="btn btn-sm btn-info" @onclick="OpenMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
|
||||
<i class="bi bi-arrow-right-circle"></i> Merge
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAllAlbums">
|
||||
@(albumsAllSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
|
||||
Done
|
||||
</button>
|
||||
} else {
|
||||
@@ -35,33 +50,121 @@
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetMasonry" title="Masonry">
|
||||
<i class="bi bi-grid-3x2-gap"></i>
|
||||
</button>
|
||||
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetGrid" title="Grid">
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (IsAdminOrCurator) {
|
||||
<button class="btn btn-primary btn-sm" @onclick="OpenCreateForm">
|
||||
<i class="bi bi-plus-circle"></i> <span class="d-none d-sm-inline">New Album</span>
|
||||
@* Mobile: dropdown *@
|
||||
<div class="btn-group btn-group-sm d-md-none">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
}
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
@if (selectMode) {
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item btn-warning" @onclick="OpenLinkCosplayerModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Link to Cosplayer
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
|
||||
<i class="bi bi-arrow-right-circle"></i> Merge
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="ToggleSelectAllAlbums">
|
||||
<i class="bi @(albumsAllSelected ? "bi-x-square" : "bi-check2-square")"></i> @(albumsAllSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="CancelSelectMode">
|
||||
<i class="bi bi-x-circle"></i> Done
|
||||
</button>
|
||||
</li>
|
||||
} else {
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="() => selectMode = true">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenCreateForm">
|
||||
<i class="bi bi-plus-circle"></i> New Album
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetMasonry" title="Masonry">
|
||||
<i class="bi bi-grid-3x2-gap"></i>
|
||||
</button>
|
||||
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetGrid" title="Grid">
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (loginService.CanEditAll) {
|
||||
<button class="btn btn-primary btn-sm d-none d-md-inline-block" @onclick="OpenCreateForm">
|
||||
<i class="bi bi-plus-circle"></i> <span class="d-none d-sm-inline">New Album</span>
|
||||
</button>
|
||||
}
|
||||
</ActionButtons>
|
||||
</SortFilterBar>
|
||||
|
||||
<AlbumGrid SearchQuery="@searchQuery"
|
||||
@if (showVisibilityModal) {
|
||||
<VisibilityModal Show="true"
|
||||
ItemCount="@selectedAlbumIds.Count"
|
||||
ItemLabel="album(s)"
|
||||
Visibility="@selectedAlbumVisibility"
|
||||
VisibilityChanged="@((EVisibility v) => selectedAlbumVisibility = v)"
|
||||
CascadeLabel="Also change visibility of all assets in these albums (skips private/deleted)"
|
||||
CascadeEnabled="@cascadeToAssets"
|
||||
CascadeEnabledChanged="@((bool v) => cascadeToAssets = v)"
|
||||
OnApply="ApplyAlbumVisibility"
|
||||
OnClose="CloseVisibilityModal" />
|
||||
}
|
||||
|
||||
@if (showLinkCosplayerModal) {
|
||||
<LinkToCosplayerModal Show="true"
|
||||
AlbumCount="@selectedAlbumIds.Count"
|
||||
OnLink="@LinkSelectedAlbumsToCosplayer"
|
||||
OnClose="() => showLinkCosplayerModal = false" />
|
||||
}
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
Title="Confirm Deletion"
|
||||
Message="@($"Delete {selectedAlbumIds.Count} selected album{(selectedAlbumIds.Count == 1 ? "" : "s")}? This cannot be undone.")"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecuteDeleteSelectedAlbums"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
|
||||
@if (showMergeModal) {
|
||||
<MergeModal Show="true"
|
||||
ItemLabel="Album"
|
||||
Items="@mergeItems"
|
||||
OnMergeConfirmed="@ExecuteMergeAlbums"
|
||||
OnClose="() => showMergeModal = false" />
|
||||
}
|
||||
|
||||
<AlbumGrid @ref="albumGridRef"
|
||||
SearchQuery="@searchQuery"
|
||||
SortBy="@sortBy"
|
||||
SortAsc="@sortAsc"
|
||||
ViewMode="@viewMode"
|
||||
SelectionMode="@selectMode"
|
||||
SelectedIds="@selectedAlbumIds"
|
||||
OnNavigateAlbum="@NavigateToAlbum"
|
||||
OnToggleSelection="@ToggleAlbumSelection"
|
||||
LoadVersion="@loadVersion" />
|
||||
|
||||
@@ -73,13 +176,20 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
AlbumGrid? albumGridRef;
|
||||
bool showForm;
|
||||
AlbumPreviewDto? editingAlbum;
|
||||
string viewMode = "masonry";
|
||||
bool selectMode;
|
||||
bool showVisibilityModal;
|
||||
bool _showDeleteConfirm;
|
||||
bool showLinkCosplayerModal;
|
||||
bool showMergeModal;
|
||||
List<MergeModal.MergeItem> mergeItems = [];
|
||||
bool cascadeToAssets;
|
||||
EVisibility selectedAlbumVisibility = EVisibility.Public;
|
||||
HashSet<Guid> selectedAlbumIds = [];
|
||||
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
||||
|
||||
bool albumsAllSelected => albumGridRef != null && selectedAlbumIds.Count == albumGridRef.GetCurrentAlbumIds().Count && selectedAlbumIds.Count > 0;
|
||||
string? searchQuery;
|
||||
string? sortBy;
|
||||
bool sortAsc;
|
||||
@@ -120,10 +230,6 @@
|
||||
void SetMasonry() => viewMode = "masonry";
|
||||
void SetGrid() => viewMode = "grid";
|
||||
|
||||
void NavigateToAlbum(Guid id) {
|
||||
navigationManager.NavigateTo($"/albums/{id}");
|
||||
}
|
||||
|
||||
void ToggleAlbumSelection(Guid id) {
|
||||
if (selectedAlbumIds.Contains(id)) selectedAlbumIds.Remove(id);
|
||||
else selectedAlbumIds.Add(id);
|
||||
@@ -135,12 +241,81 @@
|
||||
}
|
||||
|
||||
async Task DeleteSelectedAlbums() {
|
||||
if (selectedAlbumIds.Count == 0) return;
|
||||
_showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteDeleteSelectedAlbums() {
|
||||
_showDeleteConfirm = false;
|
||||
await albumService.BulkDeleteAlbumsAsync(selectedAlbumIds.ToList());
|
||||
selectedAlbumIds.Clear();
|
||||
selectMode = false;
|
||||
loadVersion++;
|
||||
}
|
||||
|
||||
void ToggleSelectAllAlbums() {
|
||||
if (albumGridRef == null) return;
|
||||
var allIds = albumGridRef.GetCurrentAlbumIds();
|
||||
if (albumsAllSelected)
|
||||
selectedAlbumIds.Clear();
|
||||
else
|
||||
selectedAlbumIds.UnionWith(allIds);
|
||||
}
|
||||
|
||||
void OpenVisibilityModal() {
|
||||
showVisibilityModal = true;
|
||||
selectedAlbumVisibility = EVisibility.Public;
|
||||
}
|
||||
|
||||
void CloseVisibilityModal() {
|
||||
showVisibilityModal = false;
|
||||
cascadeToAssets = false;
|
||||
}
|
||||
|
||||
void OpenLinkCosplayerModal() {
|
||||
showLinkCosplayerModal = true;
|
||||
}
|
||||
|
||||
void OpenMergeModal() {
|
||||
if (albumGridRef is null) return;
|
||||
mergeItems = albumGridRef.GetCurrentAlbumPreviews()
|
||||
.Where(a => selectedAlbumIds.Contains(a.Id))
|
||||
.Select(a => new MergeModal.MergeItem { Id = a.Id, Name = a.Name, Subtitle = $"{a.AssetCount} asset{(a.AssetCount == 1 ? "" : "s")}" })
|
||||
.ToList();
|
||||
showMergeModal = true;
|
||||
}
|
||||
|
||||
async Task ExecuteMergeAlbums((Guid DestinationId, List<Guid> SourceIds) merge) {
|
||||
if (merge.SourceIds.Count == 0) return;
|
||||
var dto = new AlbumMergeDto { DestinationId = merge.DestinationId, SourceIds = merge.SourceIds };
|
||||
await albumService.MergeAlbumsAsync(dto);
|
||||
showMergeModal = false;
|
||||
selectMode = false;
|
||||
selectedAlbumIds.Clear();
|
||||
loadVersion++;
|
||||
}
|
||||
|
||||
async Task LinkSelectedAlbumsToCosplayer(Guid personId) {
|
||||
if (selectedAlbumIds.Count == 0) return;
|
||||
await albumService.BulkUpdateAlbumsAsync([.. selectedAlbumIds], new AlbumUpdateDto { Person = personId });
|
||||
showLinkCosplayerModal = false;
|
||||
selectMode = false;
|
||||
selectedAlbumIds.Clear();
|
||||
loadVersion++;
|
||||
}
|
||||
|
||||
async Task ApplyAlbumVisibility() {
|
||||
if (selectedAlbumIds.Count == 0) return;
|
||||
await albumService.BulkUpdateAlbumsAsync([.. selectedAlbumIds], new AlbumUpdateDto { Visibility = selectedAlbumVisibility });
|
||||
if (cascadeToAssets)
|
||||
await albumService.CascadeVisibilityToAssetsAsync([.. selectedAlbumIds], selectedAlbumVisibility);
|
||||
showVisibilityModal = false;
|
||||
cascadeToAssets = false;
|
||||
selectMode = false;
|
||||
selectedAlbumIds.Clear();
|
||||
loadVersion++;
|
||||
}
|
||||
|
||||
void OpenCreateForm() {
|
||||
editingAlbum = null;
|
||||
showForm = true;
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
@page "/cosplayer/{Id:guid}"
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@using System.Globalization
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@inject PersonService personService
|
||||
@inject AlbumService albumService
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject NavigationManager navigationManager
|
||||
@inject IJSRuntime jsRuntime
|
||||
|
||||
<PageTitle>@(person?.Name ?? "Cosplayer")</PageTitle>
|
||||
|
||||
@@ -53,12 +52,37 @@
|
||||
<h2 class="cosplayer-name">@person.Name</h2>
|
||||
<div class="cosplayer-stats">
|
||||
<span><i class="bi bi-collection"></i> @person.TotalAlbums albums</span>
|
||||
@if (selectMode && loginService.CanEdit(Id) && person != null && person.Albums != null) {
|
||||
var pub = person.Albums.Count(a => a.Visibility == EVisibility.Public);
|
||||
var prot = person.Albums.Count(a => a.Visibility == EVisibility.Protected);
|
||||
var priv = person.Albums.Count(a => a.Visibility == EVisibility.Private);
|
||||
<span class="mx-1">·</span>
|
||||
<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>
|
||||
@if (person.MaintainerUserIds?.Count > 0)
|
||||
{
|
||||
<span class="mx-1">·</span>
|
||||
<span>
|
||||
<i class="bi bi-people"></i>
|
||||
Maintained by:
|
||||
@for (var i = 0; i < person.MaintainerUsernames!.Count; i++)
|
||||
{
|
||||
<a href="/User/@person.MaintainerUserIds[i]" class="text-reset text-decoration-none">
|
||||
@person.MaintainerUsernames[i]
|
||||
</a>
|
||||
@if (i < person.MaintainerUsernames.Count - 1)
|
||||
{
|
||||
<text>, </text>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (IsAdminOrCurator) {
|
||||
@if (loginService.CanEdit(Id)) {
|
||||
<button class="cosplayer-banner-edit" @onclick="OpenEditForm" title="Edit Cosplayer">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
@@ -71,36 +95,49 @@
|
||||
</div>
|
||||
|
||||
<SortFilterBar SearchQuery="@albumSearchQuery"
|
||||
SearchQueryChanged="@((string? v) => { albumSearchQuery = v; _ = ReloadAlbums(); })"
|
||||
SearchQueryChanged="@((string? v) => albumSearchQuery = v)"
|
||||
SortBy="@albumSortBy"
|
||||
SortByChanged="@((string? v) => { albumSortBy = v; _ = ReloadAlbums(); })"
|
||||
SortByChanged="@((string? v) => albumSortBy = v)"
|
||||
SortAsc="@albumSortAsc"
|
||||
SortAscChanged="@((bool v) => { albumSortAsc = v; _ = ReloadAlbums(); })"
|
||||
SortAscChanged="@((bool v) => albumSortAsc = v)"
|
||||
SortOptions="@albumSortOptions">
|
||||
<ActionButtons>
|
||||
<div class="d-flex gap-2 align-items-start">
|
||||
@if (allAlbumsDict.Count > 0) {
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetMasonry" title="Masonry">
|
||||
<i class="bi bi-grid-3x2-gap"></i>
|
||||
</button>
|
||||
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetGrid" title="Grid">
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (IsAdminOrCurator) {
|
||||
@if (person.TotalAlbums > 0) {
|
||||
<div class="btn-group btn-group-sm d-none d-md-inline-flex">
|
||||
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetMasonry" title="Masonry">
|
||||
<i class="bi bi-grid-3x2-gap"></i>
|
||||
</button>
|
||||
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetGrid" title="Grid">
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (loginService.CanEdit(Id)) {
|
||||
@* Desktop: inline buttons *@
|
||||
<div class="d-none d-md-flex gap-1 align-items-center">
|
||||
<button class="btn btn-success btn-sm" @onclick="OpenAlbumAssigner">
|
||||
<i class="bi bi-plus-circle"></i> Add Albums
|
||||
</button>
|
||||
@if (allAlbumsDict.Count > 0) {
|
||||
@if (person.TotalAlbums > 0) {
|
||||
if (selectMode) {
|
||||
<button class="btn btn-warning btn-sm" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => { selectMode = false; selectedAlbumIds.Clear(); }">
|
||||
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
<button class="btn btn-sm btn-info" @onclick="OpenAlbumMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
|
||||
<i class="bi bi-arrow-right-circle"></i> Merge
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAllAlbums">
|
||||
@(albumsAllSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
|
||||
Done
|
||||
</button>
|
||||
} else {
|
||||
@@ -109,33 +146,83 @@
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<button class="btn btn-danger btn-sm" @onclick="ConfirmDelete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
<button class="btn btn-danger btn-sm" @onclick="ConfirmDelete" title="Delete cosplayer">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@* Mobile: dropdown *@
|
||||
<div class="btn-group btn-group-sm d-md-none">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenAlbumAssigner">
|
||||
<i class="bi bi-plus-circle"></i> Add Albums
|
||||
</button>
|
||||
</li>
|
||||
@if (person.TotalAlbums > 0) {
|
||||
@if (selectMode) {
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item text-warning" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenAlbumMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
|
||||
<i class="bi bi-arrow-right-circle"></i> Merge
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="ToggleSelectAllAlbums">
|
||||
<i class="bi @(albumsAllSelected ? "bi-x-square" : "bi-check2-square")"></i> @(albumsAllSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="CancelSelectMode">
|
||||
<i class="bi bi-x-circle"></i> Done
|
||||
</button>
|
||||
</li>
|
||||
} else {
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="() => selectMode = true">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
}
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" @onclick="ConfirmDelete">
|
||||
<i class="bi bi-trash"></i> Delete Cosplayer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</ActionButtons>
|
||||
</SortFilterBar>
|
||||
|
||||
@if (allAlbumsDict.Count > 0) {
|
||||
<div class="@("album-grid " + viewMode)">
|
||||
@foreach (var album in allAlbumsDict.Values) {
|
||||
<AlbumCard Album="album"
|
||||
SelectionMode="selectMode"
|
||||
Selected="selectedAlbumIds.Contains(album.Id)"
|
||||
OnNavigate="NavigateToAlbum"
|
||||
OnToggleSelection="ToggleSelectAlbum" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isLoadingMore) {
|
||||
<LoadSpinner/>
|
||||
}
|
||||
|
||||
<div @ref="sentinelRef" class="masonry-sentinel"></div>
|
||||
} else {
|
||||
<EmptyState Variant="info" Title="No albums for this cosplayer" />
|
||||
}
|
||||
<AlbumGrid @ref="personAlbumGridRef"
|
||||
SearchQuery="@albumSearchQuery"
|
||||
SortBy="@albumSortBy"
|
||||
SortAsc="@albumSortAsc"
|
||||
ViewMode="@viewMode"
|
||||
SelectionMode="@selectMode"
|
||||
SelectedIds="@selectedAlbumIds"
|
||||
OnToggleSelection="@ToggleSelectAlbum"
|
||||
LoadVersion="@albumLoadVersion"
|
||||
FetchAlbums="@FetchPersonAlbums" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -143,6 +230,7 @@
|
||||
<PersonForm Show="true"
|
||||
EditPerson="new PersonPreviewDto { Id = person.Id, Name = person.Name, ProfileAssetId = person.ProfileAssetId, ProfileCropX = person.ProfileCropX, ProfileCropY = person.ProfileCropY, ProfileCropZoom = person.ProfileCropZoom }"
|
||||
AssetIds="allAssetIds"
|
||||
MaintainerUserIds="person.MaintainerUserIds"
|
||||
OnSaved="HandleFormSaved"
|
||||
OnClosed="CloseForm" />
|
||||
}
|
||||
@@ -182,17 +270,54 @@
|
||||
</Footer>
|
||||
</ModalFrame>
|
||||
|
||||
@if (showAlbumVisibilityModal) {
|
||||
<VisibilityModal Show="true"
|
||||
ItemCount="@selectedAlbumIds.Count"
|
||||
ItemLabel="album(s)"
|
||||
Visibility="@selectedAlbumVisibility"
|
||||
VisibilityChanged="@((EVisibility v) => selectedAlbumVisibility = v)"
|
||||
CascadeLabel="Also change visibility of all assets in these albums (skips private/deleted)"
|
||||
CascadeEnabled="@cascadeToAssets"
|
||||
CascadeEnabledChanged="@((bool v) => cascadeToAssets = v)"
|
||||
OnApply="ApplyAlbumVisibility"
|
||||
OnClose="CloseVisibilityModal" />
|
||||
}
|
||||
|
||||
@if (showAlbumMergeModal) {
|
||||
<MergeModal Show="true"
|
||||
ItemLabel="Album"
|
||||
Items="@albumMergeItems"
|
||||
OnMergeConfirmed="@ExecuteMergeAlbums"
|
||||
OnClose="() => showAlbumMergeModal = false" />
|
||||
}
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
Title="Confirm Deletion"
|
||||
Message="@_deleteConfirmMessage"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecutePendingDelete"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
PersonDetailedDto? person;
|
||||
AlbumGrid? personAlbumGridRef;
|
||||
bool isLoading = true;
|
||||
bool showForm;
|
||||
bool selectMode;
|
||||
bool showAlbumVisibilityModal;
|
||||
bool showAlbumMergeModal;
|
||||
List<MergeModal.MergeItem> albumMergeItems = [];
|
||||
bool _showDeleteConfirm;
|
||||
string _deleteConfirmMessage = "";
|
||||
Func<Task>? _pendingDeleteAction;
|
||||
bool cascadeToAssets;
|
||||
EVisibility selectedAlbumVisibility = EVisibility.Public;
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
||||
string viewMode = "masonry";
|
||||
bool showAlbumAssigner;
|
||||
List<AlbumPreviewDto>? unassignedAlbums;
|
||||
@@ -213,14 +338,7 @@
|
||||
["Asset count"] = "assets"
|
||||
};
|
||||
|
||||
// Infinite scroll
|
||||
Dictionary<Guid, AlbumPreviewDto> allAlbumsDict = [];
|
||||
int currentAlbumPage;
|
||||
const int AlbumPageSize = 30;
|
||||
bool hasMoreAlbums = true;
|
||||
bool isLoadingMore;
|
||||
ElementReference sentinelRef;
|
||||
DotNetObjectReference<CosplayerDetail>? dotNetRef;
|
||||
int albumLoadVersion;
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
@@ -242,70 +360,21 @@
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (allAlbumsDict.Count > 0 && dotNetRef == null) {
|
||||
dotNetRef = DotNetObjectReference.Create(this);
|
||||
await jsRuntime.InvokeVoidAsync(
|
||||
"albumObserver.observeBottom", sentinelRef, dotNetRef
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async Task LoadPerson() {
|
||||
isLoading = true;
|
||||
allAlbumsDict.Clear();
|
||||
currentAlbumPage = 0;
|
||||
hasMoreAlbums = true;
|
||||
|
||||
person = await personService.GetByIdAsync(Id, 0, AlbumPageSize);
|
||||
if (person?.Albums != null) {
|
||||
foreach (var a in person.Albums)
|
||||
allAlbumsDict[a.Id] = a;
|
||||
hasMoreAlbums = person.Albums.Count >= AlbumPageSize;
|
||||
currentAlbumPage = 1;
|
||||
}
|
||||
|
||||
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 ReloadAlbums() {
|
||||
currentAlbumPage = 0;
|
||||
hasMoreAlbums = true;
|
||||
|
||||
person = await personService.GetByIdAsync(Id, 0, AlbumPageSize, albumSearchQuery, albumSortBy, albumSortAsc);
|
||||
allAlbumsDict.Clear();
|
||||
if (person?.Albums != null) {
|
||||
foreach (var a in person.Albums)
|
||||
allAlbumsDict[a.Id] = a;
|
||||
hasMoreAlbums = person.Albums.Count >= AlbumPageSize;
|
||||
currentAlbumPage = 1;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task LoadMoreAlbums() {
|
||||
if (isLoadingMore || !hasMoreAlbums) return;
|
||||
isLoadingMore = true;
|
||||
StateHasChanged();
|
||||
|
||||
var fresh = await personService.GetByIdAsync(Id, currentAlbumPage, AlbumPageSize, albumSearchQuery, albumSortBy, albumSortAsc);
|
||||
if (fresh?.Albums?.Count > 0) {
|
||||
foreach (var a in fresh.Albums)
|
||||
allAlbumsDict[a.Id] = a;
|
||||
currentAlbumPage++;
|
||||
}
|
||||
hasMoreAlbums = fresh?.Albums?.Count >= AlbumPageSize;
|
||||
isLoadingMore = false;
|
||||
StateHasChanged();
|
||||
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;
|
||||
}
|
||||
|
||||
void OpenEditForm() => showForm = true;
|
||||
@@ -336,6 +405,40 @@
|
||||
else assignAlbumIds.Remove(id);
|
||||
}
|
||||
|
||||
bool albumsAllSelected => person?.Albums != null && person.Albums.Count > 0 && selectedAlbumIds.Count == person.Albums.Count;
|
||||
|
||||
void CancelSelectMode() {
|
||||
selectMode = false;
|
||||
selectedAlbumIds.Clear();
|
||||
}
|
||||
|
||||
void OpenAlbumMergeModal() {
|
||||
if (personAlbumGridRef is null) return;
|
||||
albumMergeItems = personAlbumGridRef.GetCurrentAlbumPreviews()
|
||||
.Where(a => selectedAlbumIds.Contains(a.Id))
|
||||
.Select(a => new MergeModal.MergeItem { Id = a.Id, Name = a.Name, Subtitle = $"{a.AssetCount} asset{(a.AssetCount == 1 ? "" : "s")}" })
|
||||
.ToList();
|
||||
showAlbumMergeModal = true;
|
||||
}
|
||||
|
||||
async Task ExecuteMergeAlbums((Guid DestinationId, List<Guid> SourceIds) merge) {
|
||||
if (merge.SourceIds.Count == 0) return;
|
||||
var dto = new AlbumMergeDto { DestinationId = merge.DestinationId, SourceIds = merge.SourceIds };
|
||||
await albumService.MergeAlbumsAsync(dto);
|
||||
showAlbumMergeModal = false;
|
||||
CancelSelectMode();
|
||||
await LoadPerson();
|
||||
albumLoadVersion++;
|
||||
}
|
||||
|
||||
void ToggleSelectAllAlbums() {
|
||||
if (person?.Albums == null) return;
|
||||
if (albumsAllSelected)
|
||||
selectedAlbumIds.Clear();
|
||||
else
|
||||
selectedAlbumIds.UnionWith(person.Albums.Select(a => a.Id));
|
||||
}
|
||||
|
||||
void ToggleSelectAlbum(Guid id) {
|
||||
if (selectedAlbumIds.Contains(id)) selectedAlbumIds.Remove(id);
|
||||
else selectedAlbumIds.Add(id);
|
||||
@@ -348,33 +451,74 @@
|
||||
showAlbumAssigner = false;
|
||||
unassignedAlbums = null;
|
||||
await LoadPerson();
|
||||
albumLoadVersion++;
|
||||
}
|
||||
|
||||
async Task RemoveSelectedAlbums() {
|
||||
if (person == null) return;
|
||||
foreach (var albumId in selectedAlbumIds)
|
||||
await albumService.UnlinkPersonAsync(albumId);
|
||||
selectMode = false;
|
||||
selectedAlbumIds.Clear();
|
||||
CancelSelectMode();
|
||||
await LoadPerson();
|
||||
albumLoadVersion++;
|
||||
}
|
||||
|
||||
async Task DeleteSelectedAlbums() {
|
||||
if (person == null || selectedAlbumIds.Count == 0) return;
|
||||
_deleteConfirmMessage = $"Delete {selectedAlbumIds.Count} selected album{(selectedAlbumIds.Count == 1 ? "" : "s")}? This cannot be undone.";
|
||||
_pendingDeleteAction = ExecuteDeleteSelectedAlbums;
|
||||
_showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteDeleteSelectedAlbums() {
|
||||
if (person == null || selectedAlbumIds.Count == 0) return;
|
||||
_showDeleteConfirm = false;
|
||||
await albumService.BulkDeleteAlbumsAsync([.. selectedAlbumIds]);
|
||||
CancelSelectMode();
|
||||
await LoadPerson();
|
||||
albumLoadVersion++;
|
||||
}
|
||||
|
||||
void OpenVisibilityModal() {
|
||||
showAlbumVisibilityModal = true;
|
||||
selectedAlbumVisibility = EVisibility.Public;
|
||||
}
|
||||
|
||||
void CloseVisibilityModal() {
|
||||
showAlbumVisibilityModal = false;
|
||||
cascadeToAssets = false;
|
||||
}
|
||||
|
||||
async Task ApplyAlbumVisibility() {
|
||||
if (person == null || selectedAlbumIds.Count == 0) return;
|
||||
await albumService.BulkUpdateAlbumsAsync([.. selectedAlbumIds], new AlbumUpdateDto { Visibility = selectedAlbumVisibility });
|
||||
if (cascadeToAssets)
|
||||
await albumService.CascadeVisibilityToAssetsAsync([.. selectedAlbumIds], selectedAlbumVisibility);
|
||||
showAlbumVisibilityModal = false;
|
||||
cascadeToAssets = false;
|
||||
CancelSelectMode();
|
||||
await LoadPerson();
|
||||
albumLoadVersion++;
|
||||
}
|
||||
|
||||
async Task ConfirmDelete() {
|
||||
if (person == null) return;
|
||||
_deleteConfirmMessage = $"Delete cosplayer '{person.Name}'? This cannot be undone.";
|
||||
_pendingDeleteAction = ExecuteDeletePerson;
|
||||
_showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteDeletePerson() {
|
||||
if (person == null) return;
|
||||
_showDeleteConfirm = false;
|
||||
await personService.DeletePersonAsync(person.Id);
|
||||
navigationManager.NavigateTo("/cosplayers");
|
||||
}
|
||||
|
||||
void NavigateToAlbum(Guid albumId) {
|
||||
navigationManager.NavigateTo($"/albums/{albumId}");
|
||||
async Task ExecutePendingDelete() {
|
||||
if (_pendingDeleteAction != null)
|
||||
await _pendingDeleteAction();
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
if (dotNetRef != null) {
|
||||
await jsRuntime.InvokeVoidAsync("albumObserver.dispose");
|
||||
dotNetRef.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
@page "/cosplayers"
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Dtos.User
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@implements IDisposable
|
||||
|
||||
@inject PersonService personService
|
||||
@inject AlbumService albumService
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject NavigationManager navigationManager
|
||||
@@ -21,13 +26,23 @@
|
||||
SortAscChanged="@((bool v) => sortAsc = v)"
|
||||
SortOptions="@sortOptions">
|
||||
<ActionButtons>
|
||||
<div class="d-flex gap-2 flex-shrink-0 flex-wrap align-items-center">
|
||||
@if (IsAdminOrCurator) {
|
||||
if (cosplayerSelectMode) {
|
||||
<button class="btn btn-danger btn-sm" @onclick="DeleteSelectedCosplayers" disabled="@(selectedCosplayerIds.Count == 0)">
|
||||
@if (loginService.IsAdminOrCurator) {
|
||||
@* Desktop: inline buttons *@
|
||||
<div class="d-none d-md-flex gap-1 align-items-center">
|
||||
@if (cosplayerSelectMode) {
|
||||
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedCosplayers" disabled="@(selectedCosplayerIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedCosplayerIds.Count
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => { cosplayerSelectMode = false; selectedCosplayerIds.Clear(); }">
|
||||
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedCosplayerIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
<button class="btn btn-sm btn-info" @onclick="OpenMergeModal" disabled="@(selectedCosplayerIds.Count < 2)">
|
||||
<i class="bi bi-arrow-right-circle"></i> Merge
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAll">
|
||||
Select All
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
|
||||
Done
|
||||
</button>
|
||||
} else {
|
||||
@@ -38,18 +53,65 @@
|
||||
<button class="btn btn-primary btn-sm" @onclick="OpenCreateForm">
|
||||
<i class="bi bi-plus-circle"></i> New Cosplayer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@* Mobile: dropdown *@
|
||||
<div class="btn-group btn-group-sm d-md-none">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
@if (cosplayerSelectMode) {
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" @onclick="DeleteSelectedCosplayers" disabled="@(selectedCosplayerIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedCosplayerIds.Count
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedCosplayerIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenMergeModal" disabled="@(selectedCosplayerIds.Count < 2)">
|
||||
<i class="bi bi-arrow-right-circle"></i> Merge
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="ToggleSelectAll">
|
||||
<i class="bi bi-check2-square"></i> Select All
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="CancelSelectMode">
|
||||
<i class="bi bi-x-circle"></i> Done
|
||||
</button>
|
||||
</li>
|
||||
} else {
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="() => cosplayerSelectMode = true">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenCreateForm">
|
||||
<i class="bi bi-plus-circle"></i> New Cosplayer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</ActionButtons>
|
||||
</SortFilterBar>
|
||||
|
||||
<CosplayerGrid SearchQuery="@searchQuery"
|
||||
<CosplayerGrid @ref="cosplayerGridRef"
|
||||
SearchQuery="@searchQuery"
|
||||
SortBy="@sortBy"
|
||||
SortAsc="@sortAsc"
|
||||
SelectionMode="@cosplayerSelectMode"
|
||||
SelectedIds="@selectedCosplayerIds"
|
||||
OnCardClicked="@NavigateToPerson"
|
||||
OnToggleSelection="@ToggleCosplayerSelection"
|
||||
SelectedIds="@selectedCosplayerIds"
|
||||
OnToggleSelection="@ToggleCosplayerSelection"
|
||||
LoadVersion="@loadVersion" />
|
||||
|
||||
@if (showCreateForm) {
|
||||
@@ -59,12 +121,50 @@
|
||||
OnClosed="CloseForm" />
|
||||
}
|
||||
|
||||
@if (showVisibilityModal) {
|
||||
<VisibilityModal Show="true"
|
||||
ItemCount="@selectedCosplayerIds.Count"
|
||||
ItemLabel="cosplayer(s)"
|
||||
Visibility="@selectedVisibility"
|
||||
VisibilityChanged="@((EVisibility v) => selectedVisibility = v)"
|
||||
CascadeLabel="Change visibility of all albums of these cosplayers (skips private/deleted)"
|
||||
CascadeEnabled="@cascadeToAlbums"
|
||||
CascadeEnabledChanged="@((bool v) => cascadeToAlbums = v)"
|
||||
CascadeLabel2="Change visibility of all assets in every album (skips private/deleted)"
|
||||
CascadeEnabled2="@cascadeToAssets"
|
||||
CascadeEnabled2Changed="@((bool v) => cascadeToAssets = v)"
|
||||
OnApply="ApplyVisibility"
|
||||
OnClose="CloseVisibilityModal" />
|
||||
}
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
Title="Confirm Deletion"
|
||||
Message="@($"Delete {selectedCosplayerIds.Count} selected cosplayer{(selectedCosplayerIds.Count == 1 ? "" : "s")}? This cannot be undone.")"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecuteDeleteSelectedCosplayers"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
|
||||
@if (showMergeModal) {
|
||||
<MergeModal Show="true"
|
||||
ItemLabel="Cosplayer"
|
||||
Items="@mergeItems"
|
||||
OnMergeConfirmed="@ExecuteMergePeople"
|
||||
OnClose="() => showMergeModal = false" />
|
||||
}
|
||||
|
||||
@code {
|
||||
CosplayerGrid? cosplayerGridRef;
|
||||
bool showCreateForm;
|
||||
bool cosplayerSelectMode;
|
||||
bool showVisibilityModal;
|
||||
bool showMergeModal;
|
||||
List<MergeModal.MergeItem> mergeItems = [];
|
||||
bool _showDeleteConfirm;
|
||||
bool cascadeToAlbums;
|
||||
bool cascadeToAssets;
|
||||
EVisibility selectedVisibility = EVisibility.Public;
|
||||
HashSet<Guid> selectedCosplayerIds = [];
|
||||
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
||||
|
||||
string? searchQuery;
|
||||
string? sortBy;
|
||||
bool sortAsc = true;
|
||||
@@ -98,15 +198,25 @@
|
||||
|
||||
void OnLoggedUserChanged(object? _, UserInfoDto? _2) => StateHasChanged();
|
||||
|
||||
void NavigateToPerson(Guid id) {
|
||||
navigationManager.NavigateTo($"/cosplayer/{id}");
|
||||
}
|
||||
|
||||
void ToggleCosplayerSelection(Guid id) {
|
||||
if (selectedCosplayerIds.Contains(id)) selectedCosplayerIds.Remove(id);
|
||||
else selectedCosplayerIds.Add(id);
|
||||
}
|
||||
|
||||
void CancelSelectMode() {
|
||||
cosplayerSelectMode = false;
|
||||
selectedCosplayerIds.Clear();
|
||||
}
|
||||
|
||||
void ToggleSelectAll() {
|
||||
if (cosplayerGridRef == null) return;
|
||||
var allIds = cosplayerGridRef.GetCurrentPersonIds();
|
||||
if (allIds.Count > 0 && selectedCosplayerIds.Count == allIds.Count)
|
||||
selectedCosplayerIds.Clear();
|
||||
else
|
||||
selectedCosplayerIds.UnionWith(allIds);
|
||||
}
|
||||
|
||||
void OpenCreateForm() {
|
||||
showCreateForm = true;
|
||||
}
|
||||
@@ -121,10 +231,80 @@
|
||||
}
|
||||
|
||||
async Task DeleteSelectedCosplayers() {
|
||||
foreach (var id in selectedCosplayerIds.ToList())
|
||||
await personService.DeletePersonAsync(id);
|
||||
selectedCosplayerIds.Clear();
|
||||
cosplayerSelectMode = false;
|
||||
if (selectedCosplayerIds.Count == 0) return;
|
||||
_showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteDeleteSelectedCosplayers() {
|
||||
_showDeleteConfirm = false;
|
||||
await personService.BulkDeletePeopleAsync([.. selectedCosplayerIds]);
|
||||
CancelSelectMode();
|
||||
loadVersion++;
|
||||
}
|
||||
|
||||
void OpenMergeModal() {
|
||||
if (cosplayerGridRef is null) return;
|
||||
mergeItems = cosplayerGridRef.GetCurrentPersonPreviews()
|
||||
.Where(p => selectedCosplayerIds.Contains(p.Id))
|
||||
.Select(p => new MergeModal.MergeItem { Id = p.Id, Name = p.Name, Subtitle = $"{p.TotalAlbums} album{(p.TotalAlbums == 1 ? "" : "s")}" })
|
||||
.ToList();
|
||||
showMergeModal = true;
|
||||
}
|
||||
|
||||
async Task ExecuteMergePeople((Guid DestinationId, List<Guid> SourceIds) merge) {
|
||||
if (merge.SourceIds.Count == 0) return;
|
||||
var dto = new PersonMergeDto { DestinationId = merge.DestinationId, SourceIds = merge.SourceIds };
|
||||
await personService.MergePeopleAsync(dto);
|
||||
showMergeModal = false;
|
||||
CancelSelectMode();
|
||||
loadVersion++;
|
||||
}
|
||||
|
||||
void OpenVisibilityModal() {
|
||||
showVisibilityModal = true;
|
||||
selectedVisibility = EVisibility.Public;
|
||||
cascadeToAlbums = false;
|
||||
cascadeToAssets = false;
|
||||
}
|
||||
|
||||
void CloseVisibilityModal() {
|
||||
showVisibilityModal = false;
|
||||
cascadeToAlbums = false;
|
||||
cascadeToAssets = false;
|
||||
}
|
||||
|
||||
async Task ApplyVisibility() {
|
||||
if (selectedCosplayerIds.Count == 0) return;
|
||||
foreach (var personId in selectedCosplayerIds) {
|
||||
var person = await personService.GetByIdAsync(personId);
|
||||
await personService.UpdatePersonAsync(personId, new PersonUpdateDto {
|
||||
Name = person?.Name ?? "",
|
||||
ProfileAssetId = person?.ProfileAssetId,
|
||||
ProfileCropX = person?.ProfileCropX,
|
||||
ProfileCropY = person?.ProfileCropY,
|
||||
ProfileCropZoom = person?.ProfileCropZoom,
|
||||
Visibility = selectedVisibility
|
||||
});
|
||||
}
|
||||
if (cascadeToAlbums) {
|
||||
var allAlbumIds = new List<Guid>();
|
||||
foreach (var personId in selectedCosplayerIds) {
|
||||
var person = await personService.GetByIdAsync(personId, 0, 1000);
|
||||
var albumIds = person?.Albums?
|
||||
.Where(a => a.Visibility.HasValue && a.Visibility.Value != EVisibility.Private)
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
if (albumIds?.Count > 0)
|
||||
allAlbumIds.AddRange(albumIds);
|
||||
}
|
||||
if (allAlbumIds.Count > 0) {
|
||||
await albumService.BulkUpdateAlbumsAsync(allAlbumIds, new AlbumUpdateDto { Visibility = selectedVisibility });
|
||||
if (cascadeToAssets)
|
||||
await albumService.CascadeVisibilityToAssetsAsync(allAlbumIds, selectedVisibility);
|
||||
}
|
||||
}
|
||||
showVisibilityModal = false;
|
||||
CancelSelectMode();
|
||||
loadVersion++;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,11 +29,12 @@
|
||||
}
|
||||
|
||||
<div class="masonry-grid">
|
||||
@for (int i = 0; i < flatList.Count; i++) {
|
||||
var asset = flatList[i];
|
||||
@{ var idx = 0; }
|
||||
@foreach (var asset in flatList) {
|
||||
var capturedIndex = idx;
|
||||
<div class="masonry-tile" @key="asset.Id"
|
||||
style="aspect-ratio:@GetAspectRatio(asset)"
|
||||
@onclick="() => OpenPreview(asset, i)">
|
||||
@onclick="() => OpenPreview(asset, capturedIndex)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)"
|
||||
loading="lazy" alt=""
|
||||
@@ -69,7 +70,8 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isLoadingMore) {
|
||||
@@ -257,7 +259,15 @@
|
||||
void NavigatePreview(int direction) {
|
||||
var newIndex = selectedIndex + direction;
|
||||
if (newIndex < 0 || newIndex >= flatList.Count) return;
|
||||
selectedAsset = flatList[newIndex];
|
||||
var newAsset = flatList[newIndex];
|
||||
if (newAsset.Id != selectedAsset.Id) {
|
||||
var found = flatList.FindIndex(a => a.Id == selectedAsset.Id);
|
||||
if (found < 0) return;
|
||||
newIndex = found + direction;
|
||||
if (newIndex < 0 || newIndex >= flatList.Count) return;
|
||||
newAsset = flatList[newIndex];
|
||||
}
|
||||
selectedAsset = newAsset;
|
||||
selectedIndex = newIndex;
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
NavigationManager.NavigateToLogin();
|
||||
return;
|
||||
}
|
||||
if (LoginService.LoggedUser?.AccessLevel != EAccessLevel.Admin) {
|
||||
if (!LoginService.IsAdmin) {
|
||||
NavigationManager.NavigateTo("/");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@page "/maintenance"
|
||||
@using MilkStream.Client.Components.Shared
|
||||
|
||||
@inject LoginService loginService
|
||||
@inject NavigationManager navigationManager
|
||||
|
||||
<PageTitle>Asset Maintenance</PageTitle>
|
||||
|
||||
@if (!isAuthorized) {
|
||||
<EmptyState Variant="danger" Title="Access Denied">
|
||||
<p>You do not have permission to access this page.</p>
|
||||
</EmptyState>
|
||||
} else {
|
||||
<AlbumAssetPicker Embedded="true"
|
||||
Show="true"
|
||||
Mode="AlbumAssetPicker.PickerMode.Maintenance"
|
||||
UnlinkedOnly="false"
|
||||
OnClosed="GoHome" />
|
||||
}
|
||||
|
||||
@code {
|
||||
bool isAuthorized => loginService.IsAdmin;
|
||||
|
||||
void GoHome() => navigationManager.NavigateTo("/");
|
||||
|
||||
protected override void OnInitialized() {
|
||||
if (!isAuthorized)
|
||||
GoHome();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
::deep .picker-embedded {
|
||||
margin: -0.75rem -1rem;
|
||||
width: calc(100% + 2rem);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
::deep .picker-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
::deep .picker-embedded-footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
@if (_folders.Any()) {
|
||||
@foreach (var folder in _folders) {
|
||||
<FolderBox Folder="folder" OnDeleted="HandleFolderDeleted"/>
|
||||
<FolderBox Folder="folder" OnDeleteRequested="RequestFolderDelete"/>
|
||||
}
|
||||
} else {
|
||||
<div class="alert alert-secondary">No folders configured yet.</div>
|
||||
@@ -77,6 +77,10 @@
|
||||
MaxPowerValue="Convert.ToInt32(setting.Options?[1])"
|
||||
Step="Convert.ToInt32(setting.Options?[2])"/>
|
||||
|
||||
break;
|
||||
case DisplayType.UserSelect:
|
||||
<SettingUserSelect Setting="setting"/>
|
||||
|
||||
break;
|
||||
default:
|
||||
<div class="alert alert-warning py-1 small">Unsupported: @setting.DisplayType</div>
|
||||
@@ -91,12 +95,20 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
Title="Confirm Deletion"
|
||||
Message="@_deleteConfirmMessage"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecuteFolderDelete"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
|
||||
@code {
|
||||
private record GroupInfo(string Icon, string[] SettingNames);
|
||||
|
||||
private static readonly Dictionary<string, GroupInfo> _groups = new() {
|
||||
["General"] = new("bi-gear-wide-connected", ["User Registration Enabled"]),
|
||||
["Scanning"] = new("bi-arrow-repeat", ["Folder Scan Enabled", "Folder Scan Interval"]),
|
||||
["Scanning"] = new("bi-arrow-repeat", ["Folder Scan Enabled", "Folder Scan Interval", "System Uploader Id"]),
|
||||
["Upload"] = new("bi-upload", ["File Upload Enabled", "File Upload MaxSize"]),
|
||||
["Paths"] = new("bi-folder2", ["Thumbnail Path", "Preview Path"]),
|
||||
["Jobs"] = new("bi-kanban-fill", ["Thumbnail Size", "Preview Size", "Thumbnail Quality", "Preview Quality", "Max Concurrent Jobs", "Job Batch Size", "Job Retention Days"]),
|
||||
@@ -107,13 +119,16 @@
|
||||
private List<FolderFullDto> _folders = new();
|
||||
private string _activeGroup = "General";
|
||||
private bool _isLoading = true;
|
||||
bool _showDeleteConfirm;
|
||||
string _deleteConfirmMessage = "";
|
||||
Guid _pendingDeleteFolderId;
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
if (!LoginService.IsLoggedIn) {
|
||||
NavigationManager.NavigateToLogin();
|
||||
return;
|
||||
}
|
||||
if (LoginService.LoggedUser?.AccessLevel != EAccessLevel.Admin) {
|
||||
if (!LoginService.IsAdmin) {
|
||||
NavigationManager.NavigateTo("/");
|
||||
return;
|
||||
}
|
||||
@@ -164,6 +179,19 @@
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
void RequestFolderDelete(Guid id) {
|
||||
var folder = _folders.FirstOrDefault(f => f.Id == id);
|
||||
_deleteConfirmMessage = $"Delete folder '{folder?.BasePath}'? This cannot be undone.";
|
||||
_pendingDeleteFolderId = id;
|
||||
_showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteFolderDelete() {
|
||||
_showDeleteConfirm = false;
|
||||
await FoldersService.RemoveFolderAsync(_pendingDeleteFolderId);
|
||||
HandleFolderDeleted(_pendingDeleteFolderId);
|
||||
}
|
||||
|
||||
void HandleFolderDeleted(Guid id) {
|
||||
_folders.RemoveAll(f => f.Id == id);
|
||||
StateHasChanged();
|
||||
|
||||
@@ -43,13 +43,14 @@
|
||||
<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.CosplayersMissingCover.ToString("N0")</h5><small class="text-muted"><i class="bi bi-person-badge"></i> Cosplayers 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>
|
||||
|
||||
@* ---- Duplicate / Orphan / Visibility ---- *@
|
||||
<div class="row row-cols-1 row-cols-md-3 g-3 mb-4">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,58 +1,88 @@
|
||||
@page "/User/{UserId:guid}"
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Dtos.User
|
||||
@using Butter.Types
|
||||
@implements IDisposable
|
||||
|
||||
@inject LoginService LoginService
|
||||
@inject UserService UserService
|
||||
@inject PersonService PersonService
|
||||
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="card mb-3" style="max-width: 600px; width: 100%;">
|
||||
<div class="card-header d-flex align-items-center gap-3">
|
||||
<div class="profile-avatar">@_initials</div>
|
||||
<div class="min-width-0">
|
||||
<h5 class="mb-0 text-truncate">@(user?.Username ?? "...")</h5>
|
||||
@if (isSelf || AdminAccess) {
|
||||
<small class="text-muted text-truncate d-block">@user?.Email</small>
|
||||
}
|
||||
</div>
|
||||
<span class="badge @RoleBadge ms-auto">@(user?.AccessLevel.ToString() ?? "")</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 g-2">
|
||||
@if (isSelf || AdminAccess) {
|
||||
<dt class="col-sm-4 text-muted small">Email</dt>
|
||||
<dd class="col-sm-8">@user?.Email</dd>
|
||||
}
|
||||
<dt class="col-sm-4 text-muted small">Role</dt>
|
||||
<dd class="col-sm-8">@user?.AccessLevel.ToString()</dd>
|
||||
<dt class="col-sm-4 text-muted small">Created</dt>
|
||||
<dd class="col-sm-8">@user?.CreatedAt.ToString("D")</dd>
|
||||
<dt class="col-sm-4 text-muted small">Last Login</dt>
|
||||
<dd class="col-sm-8">@(user?.LastLogin?.ToString("D") ?? "Never")</dd>
|
||||
<dt class="col-sm-4 text-muted small">Banned</dt>
|
||||
<dd class="col-sm-8">@(user is {IsBanned: true} ? $"Yes ({user.BannedAt.ToString()})" : "No")</dd>
|
||||
@if (AdminAccess) {
|
||||
<dt class="col-sm-4 text-muted small">Deleted</dt>
|
||||
<dd class="col-sm-8">@(user is not {DeletedAt: null} ? user!.DeletedAt.ToString() : "No")</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
@if (canEdit) {
|
||||
<div class="card-footer d-flex align-items-center gap-2 flex-wrap">
|
||||
@if (!string.IsNullOrEmpty(_pageMessage)) {
|
||||
<div class="alert alert-success py-1 mb-0 flex-grow-1">@_pageMessage</div>
|
||||
}
|
||||
<button class="btn btn-primary btn-sm" @onclick="OpenEditModal">
|
||||
<i class="bi bi-pencil"></i> Edit Profile
|
||||
</button>
|
||||
@if (isSelf) {
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => _showPasswordModal = true">
|
||||
<i class="bi bi-key"></i> Change Password
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_isLoading) {
|
||||
<LoadSpinner />
|
||||
} else if (user == null) {
|
||||
<div class="d-flex justify-content-center">
|
||||
<EmptyState Variant="danger" Title="User not found">
|
||||
<p>The user you are looking for does not exist or you do not have access.</p>
|
||||
</EmptyState>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="card mb-3" style="max-width: 600px; width: 100%;">
|
||||
<div class="card-header d-flex align-items-center gap-3">
|
||||
<div class="profile-avatar">@_initials</div>
|
||||
<div class="min-width-0">
|
||||
<h5 class="mb-0 text-truncate">@user.Username</h5>
|
||||
@if (isSelf || AdminAccess) {
|
||||
<small class="text-muted text-truncate d-block">@user.Email</small>
|
||||
}
|
||||
</div>
|
||||
<span class="badge @RoleBadge ms-auto">@user.AccessLevel.ToString()</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 g-2">
|
||||
@if (isSelf || AdminAccess) {
|
||||
<dt class="col-sm-4 text-muted small">Email</dt>
|
||||
<dd class="col-sm-8">@user.Email</dd>
|
||||
}
|
||||
<dt class="col-sm-4 text-muted small">Role</dt>
|
||||
<dd class="col-sm-8">@user.AccessLevel.ToString()</dd>
|
||||
@if (AdminAccess) {
|
||||
<dt class="col-sm-4 text-muted small">Created</dt>
|
||||
<dd class="col-sm-8">@user.CreatedAt.ToString("D")</dd>
|
||||
}
|
||||
<dt class="col-sm-4 text-muted small">Last Login</dt>
|
||||
<dd class="col-sm-8">@(user.LastLogin?.ToString("D") ?? "Never")</dd>
|
||||
<dt class="col-sm-4 text-muted small">Banned</dt>
|
||||
<dd class="col-sm-8">@(user.IsBanned ? $"Yes ({user.BannedAt.ToString()})" : "No")</dd>
|
||||
@if (AdminAccess) {
|
||||
<dt class="col-sm-4 text-muted small">Deleted</dt>
|
||||
<dd class="col-sm-8">@(user.DeletedAt?.ToString("D") ?? "No")</dd>
|
||||
}
|
||||
@if (user.MaintainedPersonNames is { Count: > 0 } names && user.MaintainedPersonIds is { Count: > 0 } ids)
|
||||
{
|
||||
<dt class="col-sm-4 text-muted small">Maintainer of</dt>
|
||||
<dd class="col-sm-8">
|
||||
@for (var i = 0; i < names.Count; i++)
|
||||
{
|
||||
<a href="/cosplayer/@ids[i]" class="text-reset text-decoration-none">@names[i]</a>
|
||||
@if (i < names.Count - 1)
|
||||
{
|
||||
<text>, </text>
|
||||
}
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
@if (canEdit) {
|
||||
<div class="card-footer d-flex align-items-center gap-2 flex-wrap">
|
||||
@if (!string.IsNullOrEmpty(_pageMessage)) {
|
||||
<div class="alert alert-success py-1 mb-0 flex-grow-1">@_pageMessage</div>
|
||||
}
|
||||
<button class="btn btn-primary btn-sm" @onclick="OpenEditModal">
|
||||
<i class="bi bi-pencil"></i> Edit Profile
|
||||
</button>
|
||||
@if (isSelf) {
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => _showPasswordModal = true">
|
||||
<i class="bi bi-key"></i> Change Password
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ModalFrame Title="Edit Profile" Show="_showEditModal" OnClose="CloseEditModal">
|
||||
<HeaderActions><i class="bi bi-pencil me-1"></i></HeaderActions>
|
||||
@@ -60,6 +90,7 @@
|
||||
@if (!string.IsNullOrEmpty(_editError)) {
|
||||
<div class="alert alert-danger py-1">@_editError</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" @bind="_editUsername" @bind:event="oninput" />
|
||||
@@ -68,6 +99,62 @@
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" @bind="_editEmail" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
@if (AdminAccess)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Level</label>
|
||||
<select class="form-select" @bind="_editAccessLevel">
|
||||
@foreach (var level in Enum.GetValues<EAccessLevel>()) {
|
||||
<option value="@level">@level</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h6>Maintained Cosplayers</h6>
|
||||
<div class="mb-3">
|
||||
<SearchAssignBadges TItem="PersonPreviewDto"
|
||||
Placeholder="Search cosplayers..."
|
||||
SearchQuery="@_personSearchQuery"
|
||||
OnSearch="HandlePersonSearchInput"
|
||||
SearchResults="@_personSearchResults"
|
||||
SelectedItems="@_assignedPersons"
|
||||
OnSelect="SelectPerson"
|
||||
OnRemove="RemoveAssignedPerson">
|
||||
<ItemDisplay Context="person">
|
||||
@person.Name
|
||||
</ItemDisplay>
|
||||
</SearchAssignBadges>
|
||||
|
||||
@if (_maintainedPersonIds.Count > 0)
|
||||
{
|
||||
<div class="mt-1">
|
||||
<small class="text-muted">@_maintainedPersonIds.Count cosplayer@(_maintainedPersonIds.Count != 1 ? "s" : "") selected</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h6>Danger Zone</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@if (user is { IsBanned: true }) {
|
||||
<button class="btn btn-success btn-sm" @onclick="() => ToggleBan(false)">
|
||||
<i class="bi bi-unlock"></i> Unban
|
||||
</button>
|
||||
} else {
|
||||
<button class="btn btn-warning btn-sm" @onclick="() => ToggleBan(true)">
|
||||
<i class="bi bi-lock"></i> Ban
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => _showPasswordModal = true">
|
||||
<i class="bi bi-key"></i> Change Password
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" disabled="@(user?.DeletedAt != null)" @onclick="ConfirmDelete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</Body>
|
||||
<Footer>
|
||||
<button class="btn btn-secondary" @onclick="CloseEditModal">Cancel</button>
|
||||
@@ -84,38 +171,87 @@
|
||||
<div class="alert alert-danger py-1">@_passwordError</div>
|
||||
}
|
||||
|
||||
<PasswordField @ref="_passwordField"
|
||||
ShowOldPassword="true"
|
||||
PasswordLabel="New Password"
|
||||
ConfirmPasswordLabel="Confirm New Password"
|
||||
@bind-OldPassword="_oldPassword"
|
||||
@bind-Password="_newPassword"
|
||||
@bind-ConfirmPassword="_confirmPassword" />
|
||||
@if (AdminAccess && !isSelf)
|
||||
{
|
||||
<p class="text-muted small">Changing password for <strong>@user?.Username</strong></p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Password</label>
|
||||
<div class="input-group">
|
||||
<input type="@(_showNewPassword ? "text" : "password")" class="form-control border-end-0" @bind="_adminNewPassword" @bind:event="oninput" />
|
||||
<button class="btn btn-outline-secondary" type="button" @onclick="() => _showNewPassword = !_showNewPassword" tabindex="-1">
|
||||
<i class="bi @(_showNewPassword ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<div class="input-group">
|
||||
<input type="@(_showConfirmPassword ? "text" : "password")" class="form-control border-end-0" @bind="_adminConfirmPassword" @bind:event="oninput" />
|
||||
<button class="btn btn-outline-secondary" type="button" @onclick="() => _showConfirmPassword = !_showConfirmPassword" tabindex="-1">
|
||||
<i class="bi @(_showConfirmPassword ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(_adminConfirmPassword) && _adminNewPassword != _adminConfirmPassword) {
|
||||
<small class="text-danger">Passwords do not match</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<PasswordField @ref="_passwordField"
|
||||
ShowOldPassword="true"
|
||||
PasswordLabel="New Password"
|
||||
ConfirmPasswordLabel="Confirm New Password"
|
||||
@bind-OldPassword="_oldPassword"
|
||||
@bind-Password="_newPassword"
|
||||
@bind-ConfirmPassword="_confirmPassword" />
|
||||
}
|
||||
</Body>
|
||||
<Footer>
|
||||
<button class="btn btn-secondary" @onclick="ClosePasswordModal">Cancel</button>
|
||||
<button class="btn btn-primary" disabled="@(!_canSubmit)" @onclick="SubmitPasswordChange">
|
||||
Update Password
|
||||
</button>
|
||||
@if (AdminAccess && !isSelf)
|
||||
{
|
||||
<button class="btn btn-primary" disabled="@(string.IsNullOrWhiteSpace(_adminNewPassword) || _adminNewPassword != _adminConfirmPassword)"
|
||||
@onclick="SubmitAdminPasswordChange">
|
||||
Update Password
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-primary" disabled="@(!_canSubmit)" @onclick="SubmitPasswordChange">
|
||||
Update Password
|
||||
</button>
|
||||
}
|
||||
</Footer>
|
||||
</ModalFrame>
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
Title="Confirm Deletion"
|
||||
Message="@($"Delete user '{user?.Username}'? This can be undone.")"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecuteDelete"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
bool _isLoading;
|
||||
bool _showDeleteConfirm;
|
||||
bool isSelf;
|
||||
bool AdminAccess => LoginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
|
||||
bool AdminAccess => LoginService.IsAdmin;
|
||||
bool canEdit => isSelf || AdminAccess;
|
||||
UserInfoDto? user;
|
||||
|
||||
string _initials => user?.Username is { Length: > 0 } u ? u[..1].ToUpper() : "?";
|
||||
|
||||
string RoleBadge => user?.AccessLevel switch {
|
||||
EAccessLevel.Admin => "bg-danger",
|
||||
EAccessLevel.Curator => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
EAccessLevel.Admin => "badge-role-admin",
|
||||
EAccessLevel.Curator => "badge-role-curator",
|
||||
EAccessLevel.Maintainer => "badge-role-maintainer",
|
||||
_ => "badge-role-user"
|
||||
};
|
||||
|
||||
bool _showEditModal;
|
||||
@@ -125,48 +261,82 @@
|
||||
string _editError = "";
|
||||
string _pageMessage = "";
|
||||
|
||||
EAccessLevel _editAccessLevel;
|
||||
|
||||
string _oldPassword = "";
|
||||
string _newPassword = "";
|
||||
string _confirmPassword = "";
|
||||
string _passwordError = "";
|
||||
|
||||
string _adminNewPassword = "";
|
||||
string _adminConfirmPassword = "";
|
||||
bool _showNewPassword;
|
||||
bool _showConfirmPassword;
|
||||
|
||||
PasswordField? _passwordField;
|
||||
|
||||
bool _canSubmit => _passwordField?.IsValid == true
|
||||
&& !string.IsNullOrEmpty(_oldPassword)
|
||||
&& !string.IsNullOrEmpty(_newPassword);
|
||||
|
||||
HashSet<Guid> _maintainedPersonIds = [];
|
||||
List<PersonPreviewDto> _assignedPersons = [];
|
||||
string _personSearchQuery = "";
|
||||
List<PersonPreviewDto>? _personSearchResults;
|
||||
CancellationTokenSource? _searchCts;
|
||||
|
||||
protected async override Task OnInitializedAsync() {
|
||||
await base.OnInitializedAsync();
|
||||
_isLoading = true;
|
||||
if (LoginService.IsLoggedIn) await LoadUser();
|
||||
LoginService.AuthInfoChanged += async (_, _) => await LoadUser();
|
||||
}
|
||||
|
||||
async Task LoadUser() {
|
||||
if (UserId == LoginService.AuthInfo?.UserId) isSelf = true;
|
||||
user = await UserService.GetUserAsync();
|
||||
_isLoading = true;
|
||||
isSelf = UserId == LoginService.AuthInfo?.UserId;
|
||||
user = await UserService.GetUserByIdAsync(UserId);
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
void OpenEditModal() {
|
||||
_editUsername = user?.Username ?? "";
|
||||
_editEmail = user?.Email ?? "";
|
||||
_editAccessLevel = user?.AccessLevel ?? EAccessLevel.User;
|
||||
_editError = "";
|
||||
_maintainedPersonIds = [];
|
||||
_assignedPersons = [];
|
||||
_personSearchResults = null;
|
||||
_personSearchQuery = "";
|
||||
|
||||
if (user?.MaintainedPersonIds is { Count: > 0 } ids && user.MaintainedPersonNames is { Count: > 0 } names) {
|
||||
_maintainedPersonIds = [.. ids];
|
||||
for (var i = 0; i < ids.Count; i++) {
|
||||
_assignedPersons.Add(new PersonPreviewDto { Id = ids[i], Name = names[i] });
|
||||
}
|
||||
}
|
||||
_showEditModal = true;
|
||||
}
|
||||
|
||||
async Task CloseEditModal() {
|
||||
_showEditModal = false;
|
||||
_editError = "";
|
||||
_maintainedPersonIds = [];
|
||||
_assignedPersons = [];
|
||||
_personSearchResults = null;
|
||||
_personSearchQuery = "";
|
||||
}
|
||||
|
||||
async Task SubmitEditProfile() {
|
||||
_editError = "";
|
||||
|
||||
var dto = new UserUpdateDto {
|
||||
Id = UserId,
|
||||
Username = _editUsername,
|
||||
Email = _editEmail
|
||||
Id = UserId,
|
||||
Username = _editUsername,
|
||||
Email = _editEmail,
|
||||
AccessLevel = AdminAccess ? _editAccessLevel : null,
|
||||
MaintainedPersonIds = AdminAccess ? _maintainedPersonIds.ToList() : null
|
||||
};
|
||||
|
||||
var (result, error) = await UserService.UpdateUserAsync(dto);
|
||||
@@ -184,6 +354,8 @@
|
||||
_oldPassword = "";
|
||||
_newPassword = "";
|
||||
_confirmPassword = "";
|
||||
_adminNewPassword = "";
|
||||
_adminConfirmPassword = "";
|
||||
_passwordError = "";
|
||||
}
|
||||
|
||||
@@ -205,4 +377,88 @@
|
||||
}
|
||||
}
|
||||
|
||||
async Task SubmitAdminPasswordChange() {
|
||||
_passwordError = "";
|
||||
|
||||
var dto = new UserUpdateDto {
|
||||
Id = UserId,
|
||||
Password = _adminNewPassword
|
||||
};
|
||||
|
||||
var (result, error) = await UserService.UpdateUserAsync(dto);
|
||||
if (result != null) {
|
||||
_pageMessage = "Password changed.";
|
||||
await ClosePasswordModal();
|
||||
} else {
|
||||
_passwordError = error ?? "Failed to change password.";
|
||||
}
|
||||
}
|
||||
|
||||
async Task ToggleBan(bool ban) {
|
||||
var dto = new UserUpdateDto { Id = UserId, IsBanned = ban };
|
||||
var (result, _) = await UserService.UpdateUserAsync(dto);
|
||||
if (result != null) {
|
||||
_pageMessage = ban ? "User has been banned." : "User has been unbanned.";
|
||||
await CloseEditModal();
|
||||
await LoadUser();
|
||||
} else {
|
||||
_editError = $"Failed to {(ban ? "ban" : "unban")} user.";
|
||||
}
|
||||
}
|
||||
|
||||
async Task ConfirmDelete() {
|
||||
_showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteDelete() {
|
||||
_showDeleteConfirm = false;
|
||||
var success = await UserService.DeleteUserAsync(UserId);
|
||||
if (success) {
|
||||
_pageMessage = $"User deleted.";
|
||||
await CloseEditModal();
|
||||
await LoadUser();
|
||||
} else {
|
||||
_editError = "Failed to delete user.";
|
||||
}
|
||||
}
|
||||
|
||||
async Task HandlePersonSearchInput(ChangeEventArgs e) {
|
||||
_personSearchQuery = e.Value?.ToString() ?? "";
|
||||
_searchCts?.Cancel();
|
||||
_searchCts = new CancellationTokenSource();
|
||||
var token = _searchCts.Token;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_personSearchQuery) || _personSearchQuery.Length < 2) {
|
||||
_personSearchResults = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var results = await PersonService.GetAllAsync(search: _personSearchQuery, pageSize: 250);
|
||||
if (!token.IsCancellationRequested) {
|
||||
_personSearchResults = results;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
void SelectPerson(PersonPreviewDto person) {
|
||||
if (_maintainedPersonIds.Add(person.Id)) {
|
||||
_assignedPersons.Add(person);
|
||||
}
|
||||
_personSearchQuery = "";
|
||||
_personSearchResults = null;
|
||||
}
|
||||
|
||||
void RemoveAssignedPerson(PersonPreviewDto person) {
|
||||
_maintainedPersonIds.Remove(person.Id);
|
||||
_assignedPersons.RemoveAll(p => p.Id == person.Id);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose() {
|
||||
_searchCts?.Cancel();
|
||||
_searchCts?.Dispose();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
@using Butter.Dtos.User
|
||||
@using Butter.Dtos.Settings
|
||||
@inherits SettingBox
|
||||
@inject UserService UserService
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">@(Setting?.Name ?? "NO NAME")</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">@(Setting?.Description ?? "No description provided")</p>
|
||||
|
||||
<div class="position-relative">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" placeholder="Search users..."
|
||||
@bind="_searchQuery"
|
||||
@bind:after="OnSearch" />
|
||||
</div>
|
||||
|
||||
@if (_filteredUsers is { Count: > 0 })
|
||||
{
|
||||
<div class="dropdown-menu search-dropdown-menu d-block">
|
||||
@foreach (var user in _filteredUsers)
|
||||
{
|
||||
<button type="button" class="dropdown-item" @onclick="() => SelectUser(user)">
|
||||
@user.Username
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_selectedUser is not null)
|
||||
{
|
||||
<div class="d-flex flex-wrap gap-1 mt-2">
|
||||
<span class="badge bg-primary d-flex align-items-center gap-1">
|
||||
@_selectedUser.Username
|
||||
<button type="button" class="p-0 border-0 bg-transparent text-white fw-bold"
|
||||
style="font-size: 0.7rem; line-height: 1;"
|
||||
@onclick="ClearSelection">×</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
List<UserInfoDto>? _allUsers;
|
||||
List<UserInfoDto>? _filteredUsers;
|
||||
UserInfoDto? _selectedUser;
|
||||
string _searchQuery = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
base.OnInitialized();
|
||||
_allUsers = await UserService.GetAllUsersAsync();
|
||||
|
||||
if (!string.IsNullOrEmpty(Setting?.Value) && _allUsers is not null)
|
||||
_selectedUser = _allUsers.FirstOrDefault(u => u.Id.ToString() == Setting.Value);
|
||||
}
|
||||
|
||||
void OnSearch()
|
||||
{
|
||||
if (_allUsers is null || string.IsNullOrWhiteSpace(_searchQuery) || _searchQuery.Length < 2)
|
||||
{
|
||||
_filteredUsers = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_filteredUsers = _allUsers
|
||||
.Where(u => u.Username.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(u => _selectedUser is null || u.Id != _selectedUser.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
async Task SelectUser(UserInfoDto user)
|
||||
{
|
||||
_selectedUser = user;
|
||||
_searchQuery = "";
|
||||
_filteredUsers = null;
|
||||
await OnValueChanged();
|
||||
}
|
||||
|
||||
async Task ClearSelection()
|
||||
{
|
||||
_selectedUser = null;
|
||||
await OnValueChanged();
|
||||
}
|
||||
|
||||
protected override SettingDto? GetSetting()
|
||||
{
|
||||
if (Setting is null) return null;
|
||||
Setting.Value = _selectedUser?.Id.ToString() ?? "";
|
||||
return Setting;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
@using Butter.Dtos.Album
|
||||
@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
|
||||
|
||||
@if (Embedded) {
|
||||
<div class="picker-embedded">
|
||||
<div class="picker-layout">
|
||||
<div class="picker-sidebar @(_sidebarCollapsed ? "collapsed" : "")">
|
||||
<div class="sidebar-header">
|
||||
<span>@(isAdminOrCurator ? "Folders" : "Source")</span>
|
||||
<button class="sidebar-toggle" @onclick="() => _sidebarCollapsed = !_sidebarCollapsed" title="Toggle sidebar">
|
||||
<i class="bi @(_sidebarCollapsed ? "bi-chevron-right" : "bi-chevron-left")"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (!_sidebarCollapsed) {
|
||||
@if (groups != null && groups.Count > 0) {
|
||||
@foreach (var group in groups) {
|
||||
<button class="sidebar-item @(selectedGroup?.FolderId == group.FolderId ? "active" : "")"
|
||||
@onclick="() => SelectGroup(group)">
|
||||
<span class="sidebar-name">@TruncateName(group.Name)</span>
|
||||
<span class="badge bg-secondary rounded-pill">@group.AssetCount</span>
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
<div class="text-muted small p-2">No folders found.</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="picker-main">
|
||||
@if (isAdminOrCurator && selectedGroup != null) {
|
||||
<div class="picker-breadcrumb">
|
||||
<button class="breadcrumb-item @(string.IsNullOrEmpty(currentPath) ? "active" : "")"
|
||||
@onclick="NavigateToRoot">root</button>
|
||||
@foreach (var item in GetBreadcrumbs()) {
|
||||
<span class="breadcrumb-sep">/</span>
|
||||
<button class="breadcrumb-item @(item.Path == currentPath ? "active" : "")"
|
||||
@onclick="() => NavigateToPath(item.Path)">@item.Segment</button>
|
||||
}
|
||||
</div>
|
||||
<div class="picker-toolbar">
|
||||
<input class="form-control form-control-sm picker-search" type="search"
|
||||
placeholder="Search by filename..."
|
||||
@bind="searchQuery" @bind:after="OnSearchChanged" />
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => _listView = !_listView"
|
||||
title="@(_listView ? "Grid view" : "List view")">
|
||||
<i class="bi @(_listView ? "bi-grid-3x3" : "bi-list-ul")"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (browseResult?.Directories.Count > 0) {
|
||||
<div class="picker-dir-list">
|
||||
@foreach (var dir in browseResult.Directories.OrderBy(d => d.Name)) {
|
||||
<button class="picker-dir-item" @onclick="() => NavigateToPath(JoinPath(currentPath, dir.Name))">
|
||||
<i class="bi bi-folder"></i><span class="dir-name">@dir.Name</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (browseResult != null) {
|
||||
@if (browseResult.Directories.Count == 0 && browseResult.Assets.Count == 0) {
|
||||
<EmptyState Variant="info" Title="No assets at this level">
|
||||
<p>@(string.IsNullOrEmpty(searchQuery) ? "This directory contains no assets." : $"No assets matching \"{searchQuery}\".")</p>
|
||||
</EmptyState>
|
||||
} else if (browseResult.Assets.Count > 0) {
|
||||
@if (_listView) {
|
||||
<div class="picker-list">
|
||||
@foreach (var asset in browseResult.Assets) {
|
||||
<div class="picker-list-item @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
|
||||
<span class="list-name">@(asset.FileName ?? asset.Id.ToString())</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="picker-grid">
|
||||
@foreach (var asset in browseResult.Assets) {
|
||||
<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=""
|
||||
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 {
|
||||
<div class="tile-fallback"><i class="bi bi-image"></i></div>
|
||||
}
|
||||
<div class="picker-tile-check">
|
||||
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="picker-pagination">
|
||||
<button class="btn btn-sm btn-secondary" @onclick="LoadPrevious" disabled="@(currentPage <= 0)">Previous</button>
|
||||
<span class="text-muted small">@(browseResult.TotalAssetCount) assets</span>
|
||||
<button class="btn btn-sm btn-secondary" @onclick="LoadNext" disabled="@(!hasMore)">Next</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
} else if (!isAdminOrCurator && selectedGroup != null) {
|
||||
<div class="picker-toolbar">
|
||||
<input class="form-control form-control-sm picker-search" type="search"
|
||||
placeholder="Search by filename..."
|
||||
@bind="searchQuery" @bind:after="OnSearchChanged" />
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => _listView = !_listView"
|
||||
title="@(_listView ? "Grid view" : "List view")">
|
||||
<i class="bi @(_listView ? "bi-grid-3x3" : "bi-list-ul")"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (assetPage == null || assetPage.Count == 0) {
|
||||
<EmptyState Variant="info" Title="No assets found">
|
||||
<p>@(string.IsNullOrEmpty(searchQuery) ? "No assets here." : $"No assets matching \"{searchQuery}\".")</p>
|
||||
</EmptyState>
|
||||
} else {
|
||||
@if (_listView) {
|
||||
<div class="picker-list">
|
||||
@foreach (var asset in assetPage) {
|
||||
<div class="picker-list-item @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
|
||||
<span class="list-name">@(asset.FileName ?? asset.Id.ToString())</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="picker-grid">
|
||||
@foreach (var asset in assetPage) {
|
||||
<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=""
|
||||
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 {
|
||||
<div class="tile-fallback"><i class="bi bi-image"></i></div>
|
||||
}
|
||||
<div class="picker-tile-check">
|
||||
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="picker-pagination">
|
||||
<button class="btn btn-sm btn-secondary" @onclick="LoadPrevious" disabled="@(currentPage <= 0)">Previous</button>
|
||||
<button class="btn btn-sm btn-secondary" @onclick="LoadNext" disabled="@(!hasMore)">Next</button>
|
||||
</div>
|
||||
}
|
||||
} else if (isAdminOrCurator) {
|
||||
<EmptyState Variant="info" Title="Select a folder">
|
||||
<p>Choose a folder from the sidebar to browse assets.</p>
|
||||
</EmptyState>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="picker-embedded-footer">
|
||||
@if (Mode == PickerMode.Maintenance && selectedIds.Count > 0) {
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="OpenVisibilityModal"><i class="bi bi-eye"></i> Visibility</button>
|
||||
<button class="btn btn-sm btn-outline-success" @onclick="OpenCreateAlbumModal"><i class="bi bi-plus-circle"></i> Create Album</button>
|
||||
<span class="text-muted small">@selectedIds.Count selected</span>
|
||||
<span class="flex-grow-1"></span>
|
||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmDeleteSelected"><i class="bi bi-trash"></i> Delete</button>
|
||||
}
|
||||
@if (Mode == PickerMode.AddToAlbum) {
|
||||
<button class="btn btn-sm btn-success" @onclick="AddSelected" disabled="@(selectedIds.Count == 0)">Add @selectedIds.Count asset(s)</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<ModalFrame Title="@(Mode == PickerMode.Maintenance ? "Asset Browser" : "Add Assets to Album")" Show="Show" Size="lg" OnClose="OnCancel">
|
||||
<Body>
|
||||
@if (isLoading && groups == null) {
|
||||
<LoadSpinner/>
|
||||
} else {
|
||||
<div class="picker-layout">
|
||||
<div class="picker-sidebar @(_sidebarCollapsed ? "collapsed" : "")">
|
||||
<div class="sidebar-header">
|
||||
<span>@(isAdminOrCurator ? "Folders" : "Source")</span>
|
||||
<button class="sidebar-toggle" @onclick="() => _sidebarCollapsed = !_sidebarCollapsed" title="Toggle sidebar">
|
||||
<i class="bi @(_sidebarCollapsed ? "bi-chevron-right" : "bi-chevron-left")"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (!_sidebarCollapsed) {
|
||||
@if (groups != null && groups.Count > 0) {
|
||||
@foreach (var group in groups) {
|
||||
<button class="sidebar-item @(selectedGroup?.FolderId == group.FolderId ? "active" : "")"
|
||||
@onclick="() => SelectGroup(group)">
|
||||
<span class="sidebar-name">@TruncateName(group.Name)</span>
|
||||
<span class="badge bg-secondary rounded-pill">@group.AssetCount</span>
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
<div class="text-muted small p-2">No folders found.</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="picker-main">
|
||||
@if (isAdminOrCurator && selectedGroup != null) {
|
||||
<div class="picker-breadcrumb">
|
||||
<button class="breadcrumb-item @(string.IsNullOrEmpty(currentPath) ? "active" : "")"
|
||||
@onclick="NavigateToRoot">root</button>
|
||||
@foreach (var item in GetBreadcrumbs()) {
|
||||
<span class="breadcrumb-sep">/</span>
|
||||
<button class="breadcrumb-item @(item.Path == currentPath ? "active" : "")"
|
||||
@onclick="() => NavigateToPath(item.Path)">@item.Segment</button>
|
||||
}
|
||||
</div>
|
||||
<div class="picker-toolbar">
|
||||
<input class="form-control form-control-sm picker-search" type="search"
|
||||
placeholder="Search by filename..."
|
||||
@bind="searchQuery" @bind:after="OnSearchChanged" />
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => _listView = !_listView"
|
||||
title="@(_listView ? "Grid view" : "List view")">
|
||||
<i class="bi @(_listView ? "bi-grid-3x3" : "bi-list-ul")"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (browseResult?.Directories.Count > 0) {
|
||||
<div class="picker-dir-list">
|
||||
@foreach (var dir in browseResult.Directories.OrderBy(d => d.Name)) {
|
||||
<button class="picker-dir-item" @onclick="() => NavigateToPath(JoinPath(currentPath, dir.Name))">
|
||||
<i class="bi bi-folder"></i><span class="dir-name">@dir.Name</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (browseResult != null) {
|
||||
@if (browseResult.Directories.Count == 0 && browseResult.Assets.Count == 0) {
|
||||
<EmptyState Variant="info" Title="No assets at this level">
|
||||
<p>@(string.IsNullOrEmpty(searchQuery) ? "This directory contains no assets." : $"No assets matching \"{searchQuery}\".")</p>
|
||||
</EmptyState>
|
||||
} else if (browseResult.Assets.Count > 0) {
|
||||
@if (_listView) {
|
||||
<div class="picker-list">
|
||||
@foreach (var asset in browseResult.Assets) {
|
||||
<div class="picker-list-item @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
|
||||
<span class="list-name">@(asset.FileName ?? asset.Id.ToString())</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="picker-grid">
|
||||
@foreach (var asset in browseResult.Assets) {
|
||||
<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=""
|
||||
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 {
|
||||
<div class="tile-fallback"><i class="bi bi-image"></i></div>
|
||||
}
|
||||
<div class="picker-tile-check">
|
||||
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="picker-pagination">
|
||||
<button class="btn btn-sm btn-secondary" @onclick="LoadPrevious" disabled="@(currentPage <= 0)">Previous</button>
|
||||
<span class="text-muted small">@(browseResult.TotalAssetCount) assets</span>
|
||||
<button class="btn btn-sm btn-secondary" @onclick="LoadNext" disabled="@(!hasMore)">Next</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
} else if (!isAdminOrCurator && selectedGroup != null) {
|
||||
<div class="picker-toolbar">
|
||||
<input class="form-control form-control-sm picker-search" type="search"
|
||||
placeholder="Search by filename..."
|
||||
@bind="searchQuery" @bind:after="OnSearchChanged" />
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => _listView = !_listView"
|
||||
title="@(_listView ? "Grid view" : "List view")">
|
||||
<i class="bi @(_listView ? "bi-grid-3x3" : "bi-list-ul")"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (assetPage == null || assetPage.Count == 0) {
|
||||
<EmptyState Variant="info" Title="No assets found">
|
||||
<p>@(string.IsNullOrEmpty(searchQuery) ? "No assets here." : $"No assets matching \"{searchQuery}\".")</p>
|
||||
</EmptyState>
|
||||
} else {
|
||||
@if (_listView) {
|
||||
<div class="picker-list">
|
||||
@foreach (var asset in assetPage) {
|
||||
<div class="picker-list-item @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
|
||||
<span class="list-name">@(asset.FileName ?? asset.Id.ToString())</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="picker-grid">
|
||||
@foreach (var asset in assetPage) {
|
||||
<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=""
|
||||
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 {
|
||||
<div class="tile-fallback"><i class="bi bi-image"></i></div>
|
||||
}
|
||||
<div class="picker-tile-check">
|
||||
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="picker-pagination">
|
||||
<button class="btn btn-sm btn-secondary" @onclick="LoadPrevious" disabled="@(currentPage <= 0)">Previous</button>
|
||||
<button class="btn btn-sm btn-secondary" @onclick="LoadNext" disabled="@(!hasMore)">Next</button>
|
||||
</div>
|
||||
}
|
||||
} else if (isAdminOrCurator) {
|
||||
<EmptyState Variant="info" Title="Select a folder">
|
||||
<p>Choose a folder from the sidebar to browse assets.</p>
|
||||
</EmptyState>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</Body>
|
||||
<Footer>
|
||||
@if (Mode == PickerMode.Maintenance) {
|
||||
@if (selectedIds.Count > 0) {
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="OpenVisibilityModal"><i class="bi bi-eye"></i> Visibility</button>
|
||||
<button class="btn btn-sm btn-outline-success" @onclick="OpenCreateAlbumModal"><i class="bi bi-plus-circle"></i> Create Album</button>
|
||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmDeleteSelected"><i class="bi bi-trash"></i> Delete</button>
|
||||
<span class="text-muted small">@selectedIds.Count selected</span>
|
||||
}
|
||||
<span class="flex-grow-1"></span>
|
||||
<button class="btn btn-secondary" @onclick="OnCancel">Close</button>
|
||||
} else {
|
||||
<button class="btn btn-secondary" @onclick="OnCancel">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="AddSelected" disabled="@(selectedIds.Count == 0)">Add @selectedIds.Count asset(s)</button>
|
||||
}
|
||||
</Footer>
|
||||
</ModalFrame>
|
||||
}
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
Title="Confirm Deletion"
|
||||
Message="@_deleteConfirmMessage"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecuteDeleteSelected"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
|
||||
@if (_showVisibilityModal) {
|
||||
<VisibilityModal Show="true"
|
||||
ItemCount="@selectedIds.Count"
|
||||
ItemLabel="asset(s)"
|
||||
Visibility="@_visibilitySelection"
|
||||
VisibilityChanged="@((EVisibility v) => _visibilitySelection = v)"
|
||||
OnApply="ApplyVisibility"
|
||||
OnClose="() => _showVisibilityModal = false" />
|
||||
}
|
||||
|
||||
@if (_showCreateAlbumModal) {
|
||||
<ModalFrame Title="Create Album" Show="true" OnClose="() => _showCreateAlbumModal = false">
|
||||
<Body>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Album Name</label>
|
||||
<input type="text" class="form-control" @bind="_newAlbumName" />
|
||||
</div>
|
||||
<div class="mb-3 position-relative">
|
||||
<label class="form-label">Cosplayer (optional)</label>
|
||||
<input type="text" class="form-control"
|
||||
placeholder="Search cosplayers..."
|
||||
@bind="_personSearchText"
|
||||
@bind:after="OnPersonSearchChanged" />
|
||||
@if (_personSearchResults != null && _personSearchResults.Count > 0) {
|
||||
<div class="dropdown-menu show w-100" style="position:absolute;z-index:100;">
|
||||
@foreach (var p in _personSearchResults) {
|
||||
<button type="button" class="dropdown-item @(_selectedPersonId == p.Id ? "active" : "")"
|
||||
@onclick="() => SelectPerson(p)">
|
||||
@p.Name
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_selectedPersonId.HasValue) {
|
||||
<div class="mt-1 text-muted small">Selected: @_selectedPersonName</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Visibility</label>
|
||||
<select class="form-select" @bind="_newAlbumVisibility">
|
||||
<option value="@EVisibility.Public">Public</option>
|
||||
<option value="@EVisibility.Protected">Protected</option>
|
||||
<option value="@EVisibility.Private">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
</Body>
|
||||
<Footer>
|
||||
<button class="btn btn-secondary" @onclick="() => _showCreateAlbumModal = false">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="CreateAlbumWithSelection" disabled="@string.IsNullOrWhiteSpace(_newAlbumName)">
|
||||
<i class="bi bi-plus-circle"></i> Create
|
||||
</button>
|
||||
</Footer>
|
||||
</ModalFrame>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool Show { get; set; }
|
||||
[Parameter] public bool Embedded { get; set; }
|
||||
[Parameter] public EventCallback<List<Guid>> OnSelected { get; set; }
|
||||
[Parameter] public EventCallback OnClosed { get; set; }
|
||||
[Parameter] public PickerMode Mode { get; set; } = PickerMode.AddToAlbum;
|
||||
[Parameter] public bool UnlinkedOnly { get; set; } = true;
|
||||
public enum PickerMode { AddToAlbum, Maintenance }
|
||||
|
||||
List<AssetGroupDto>? groups;
|
||||
AssetGroupDto? selectedGroup;
|
||||
List<AssetPreviewDto>? assetPage;
|
||||
AssetBrowseResultDto? browseResult;
|
||||
string currentPath = "";
|
||||
HashSet<Guid> selectedIds = [];
|
||||
string searchQuery = string.Empty;
|
||||
string token = string.Empty;
|
||||
int currentPage;
|
||||
bool isLoading;
|
||||
bool hasMore = true;
|
||||
Timer? _searchTimer;
|
||||
private bool _initializing;
|
||||
private bool _sidebarCollapsed;
|
||||
private bool _listView;
|
||||
private bool _showDeleteConfirm;
|
||||
private string _deleteConfirmMessage = "";
|
||||
|
||||
private bool _showVisibilityModal;
|
||||
private EVisibility _visibilitySelection = EVisibility.Public;
|
||||
|
||||
private bool _showCreateAlbumModal;
|
||||
private string _newAlbumName = "";
|
||||
private string _personSearchText = "";
|
||||
private List<PersonPreviewDto>? _personSearchResults;
|
||||
private Guid? _selectedPersonId;
|
||||
private string _selectedPersonName = "";
|
||||
private Timer? _personSearchTimer;
|
||||
private EVisibility _newAlbumVisibility = EVisibility.Protected;
|
||||
|
||||
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();
|
||||
_initializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async Task InitializeAsync() {
|
||||
isLoading = true;
|
||||
try {
|
||||
selectedIds.Clear(); searchQuery = string.Empty; currentPage = 0; hasMore = true;
|
||||
currentPath = ""; browseResult = null; assetPage = null; selectedGroup = null;
|
||||
groups = await assetService.GetFolderGroupsAsync(UnlinkedOnly);
|
||||
if (groups != null && groups.Count > 0) {
|
||||
if (!isAdminOrCurator && groups.Count == 1) selectedGroup = groups[0];
|
||||
}
|
||||
if (selectedGroup != null) await LoadContent();
|
||||
} finally { isLoading = false; }
|
||||
}
|
||||
|
||||
async Task SelectGroup(AssetGroupDto group) {
|
||||
selectedGroup = group; selectedIds.Clear(); searchQuery = string.Empty;
|
||||
currentPage = 0; currentPath = ""; browseResult = null; assetPage = null;
|
||||
await LoadContent();
|
||||
}
|
||||
|
||||
async Task NavigateToPath(string path) { currentPath = path; currentPage = 0; selectedIds.Clear(); browseResult = null; await LoadContent(); }
|
||||
async Task NavigateToRoot() => await NavigateToPath("");
|
||||
|
||||
async Task LoadContent() {
|
||||
if (selectedGroup == null) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
if (isAdminOrCurator && selectedGroup.FolderId.HasValue) {
|
||||
browseResult = await assetService.BrowseAsync(selectedGroup.FolderId.Value, currentPath, currentPage, PageSize, string.IsNullOrEmpty(searchQuery) ? null : searchQuery, UnlinkedOnly);
|
||||
if (browseResult != null) hasMore = browseResult.Assets.Count >= PageSize;
|
||||
} else {
|
||||
assetPage = await assetService.GetAssetsAsync(unlinked: UnlinkedOnly, uploadedBy: loginService.LoggedUser?.Id, search: string.IsNullOrEmpty(searchQuery) ? null : searchQuery, page: currentPage, pageSize: PageSize, random: false);
|
||||
hasMore = assetPage != null && assetPage.Count >= PageSize;
|
||||
}
|
||||
} finally { isLoading = false; }
|
||||
}
|
||||
|
||||
async Task LoadPrevious() { if (currentPage <= 0) return; currentPage--; await LoadContent(); }
|
||||
async Task LoadNext() { if (!hasMore) return; currentPage++; await LoadContent(); }
|
||||
|
||||
async Task OnSearchChanged() {
|
||||
_searchTimer?.Dispose();
|
||||
_searchTimer = new Timer(async _ => { currentPage = 0; hasMore = true; await LoadContent(); await InvokeAsync(StateHasChanged); }, null, 300, Timeout.Infinite);
|
||||
}
|
||||
|
||||
List<(string Segment, string Path)> GetBreadcrumbs() {
|
||||
if (string.IsNullOrEmpty(currentPath)) return [];
|
||||
var segments = currentPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var result = new List<(string Segment, string Path)>();
|
||||
var accumulated = "";
|
||||
foreach (var seg in segments) {
|
||||
accumulated = string.IsNullOrEmpty(accumulated) ? seg : accumulated + "/" + seg;
|
||||
result.Add((seg, accumulated));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static string JoinPath(string? bp, string s) => string.IsNullOrEmpty(bp) ? s : bp.TrimEnd('/') + "/" + s;
|
||||
|
||||
void ToggleSelection(Guid id) { if (selectedIds.Contains(id)) selectedIds.Remove(id); else selectedIds.Add(id); }
|
||||
|
||||
async Task AddSelected() {
|
||||
var ids = selectedIds.ToList(); selectedIds.Clear();
|
||||
await OnSelected.InvokeAsync(ids); await OnClosed.InvokeAsync();
|
||||
}
|
||||
|
||||
async Task OnCancel() { selectedIds.Clear(); await OnClosed.InvokeAsync(); }
|
||||
|
||||
async Task ConfirmDeleteSelected() {
|
||||
_deleteConfirmMessage = $"Delete {selectedIds.Count} selected asset(s)? This cannot be undone.";
|
||||
_showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteDeleteSelected() {
|
||||
_showDeleteConfirm = false;
|
||||
if (selectedIds.Count == 0) return;
|
||||
await assetService.BulkDeleteAssetsAsync([.. selectedIds]);
|
||||
selectedIds.Clear();
|
||||
await LoadContent();
|
||||
}
|
||||
|
||||
void OpenVisibilityModal() {
|
||||
_visibilitySelection = EVisibility.Public;
|
||||
_showVisibilityModal = true;
|
||||
}
|
||||
|
||||
async Task ApplyVisibility() {
|
||||
if (selectedIds.Count == 0) return;
|
||||
await assetService.BulkUpdateAssetsAsync([.. selectedIds], new AssetUpdateDto { Visibility = _visibilitySelection });
|
||||
_showVisibilityModal = false;
|
||||
selectedIds.Clear();
|
||||
await LoadContent();
|
||||
}
|
||||
|
||||
void OpenCreateAlbumModal() {
|
||||
_newAlbumName = "";
|
||||
_personSearchText = "";
|
||||
_personSearchResults = null;
|
||||
_selectedPersonId = null;
|
||||
_selectedPersonName = "";
|
||||
_newAlbumVisibility = EVisibility.Protected;
|
||||
_showCreateAlbumModal = true;
|
||||
}
|
||||
|
||||
async Task OnPersonSearchChanged() {
|
||||
_personSearchTimer?.Dispose();
|
||||
_personSearchTimer = new Timer(async _ => {
|
||||
if (_personSearchText.Length < 2) {
|
||||
_personSearchResults = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
var results = await personService.GetAllAsync(search: _personSearchText, pageSize: 10);
|
||||
_personSearchResults = results;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}, null, 300, Timeout.Infinite);
|
||||
}
|
||||
|
||||
void SelectPerson(PersonPreviewDto person) {
|
||||
_selectedPersonId = person.Id;
|
||||
_selectedPersonName = person.Name;
|
||||
_personSearchText = person.Name;
|
||||
_personSearchResults = null;
|
||||
}
|
||||
|
||||
async Task CreateAlbumWithSelection() {
|
||||
if (string.IsNullOrWhiteSpace(_newAlbumName) || selectedIds.Count == 0) return;
|
||||
await albumService.CreateAlbumAsync(new AlbumCreateDto {
|
||||
Name = _newAlbumName.Trim(),
|
||||
Person = _selectedPersonId,
|
||||
Assets = [.. selectedIds],
|
||||
Visibility = _newAlbumVisibility
|
||||
});
|
||||
_showCreateAlbumModal = false;
|
||||
selectedIds.Clear();
|
||||
await LoadContent();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
_searchTimer?.Dispose();
|
||||
_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('/');
|
||||
if (lastSlash >= 0 && lastSlash < name.Length - 1) return "..." + name[lastSlash..];
|
||||
return "..." + name[^(maxLen - 3)..];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
.picker-layout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
min-height: 400px;
|
||||
max-height: 65vh;
|
||||
}
|
||||
|
||||
.picker-sidebar {
|
||||
width: 220px;
|
||||
min-width: 180px;
|
||||
border-right: 1px solid var(--bs-border-color, #dee2e6);
|
||||
overflow-y: auto;
|
||||
background: var(--surface-bg);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.picker-sidebar.collapsed {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.picker-sidebar.collapsed .sidebar-header span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.picker-sidebar.collapsed .sidebar-header {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--bs-body-color);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.08);
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.15);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.picker-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.picker-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.picker-search {
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.picker-tile {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: var(--surface-bg);
|
||||
border: 3px solid transparent;
|
||||
transition: border-color 0.15s, filter 0.15s;
|
||||
}
|
||||
|
||||
.picker-tile:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.picker-tile.selected {
|
||||
border-color: var(--bs-success, #198754);
|
||||
}
|
||||
|
||||
.picker-tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.picker-tile .tile-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.picker-tile-check {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
color: var(--bs-success, #198754);
|
||||
font-size: 1.2rem;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.picker-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.picker-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 0 8px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
cursor: pointer;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.1);
|
||||
}
|
||||
|
||||
.breadcrumb-item.active {
|
||||
color: var(--bs-body-color);
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.picker-dir-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.picker-dir-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-bg);
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bs-body-color);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.picker-dir-item:hover {
|
||||
background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.08);
|
||||
}
|
||||
|
||||
.picker-dir-item .bi-folder {
|
||||
color: var(--bs-warning, #ffc107);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.picker-dir-item .dir-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.picker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.picker-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bs-body-color);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.picker-list-item:hover {
|
||||
background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.06);
|
||||
}
|
||||
|
||||
.picker-list-item.selected {
|
||||
background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.12);
|
||||
}
|
||||
|
||||
.picker-list-item .bi-circle,
|
||||
.picker-list-item .bi-check-circle-fill {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.picker-list-item .bi-check-circle-fill {
|
||||
color: var(--bs-success, #198754);
|
||||
}
|
||||
|
||||
.picker-list-item .list-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-embedded {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 165px);
|
||||
}
|
||||
|
||||
.picker-embedded .picker-layout {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.picker-embedded-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--bs-border-color, #dee2e6);
|
||||
background: var(--surface-bg);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject LoginService loginService
|
||||
|
||||
<div class="album-card @(SelectionMode ? "album-card-select" : "")" @key="Album.Id"
|
||||
@onclick="HandleClick" @onclick:preventDefault="true">
|
||||
<div class="@GetCardClass()" @key="Album.Id">
|
||||
<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)"
|
||||
loading="lazy" alt=""
|
||||
@@ -40,9 +42,6 @@
|
||||
[Parameter]
|
||||
public AlbumPreviewDto Album { get; set; } = default!;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> OnNavigate { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool SelectionMode { get; set; }
|
||||
|
||||
@@ -58,10 +57,22 @@
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
|
||||
async Task HandleClick() {
|
||||
string GetCardClass() {
|
||||
var classes = new List<string> { "album-card" };
|
||||
if (SelectionMode) {
|
||||
if (loginService.CanEdit(Album.Person)) {
|
||||
if (Album.DeletedAt != null)
|
||||
classes.Add("border border-danger border-5");
|
||||
else if (Album.Visibility.HasValue && Album.Visibility.Value != EVisibility.Public)
|
||||
classes.Add("border border-warning border-5");
|
||||
}
|
||||
if (Selected) classes.Add("album-card-select");
|
||||
}
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
async Task HandleOverlayClick() {
|
||||
if (SelectionMode)
|
||||
await OnToggleSelection.InvokeAsync(Album.Id);
|
||||
else
|
||||
await OnNavigate.InvokeAsync(Album.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.album-card-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
filter: brightness(1.15);
|
||||
transform: translateY(-2px);
|
||||
@@ -64,6 +72,9 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.album-card-count {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Types
|
||||
@inject AlbumService albumService
|
||||
@inject PersonService personService
|
||||
@inject IJSRuntime JSRuntime
|
||||
@@ -12,14 +13,43 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Person</label>
|
||||
<select class="form-select" @bind="selectedPersonId">
|
||||
<option value="">None</option>
|
||||
@if (people != null) {
|
||||
@foreach (var person in people) {
|
||||
<option value="@person.Id">@person.Name</option>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="position-relative flex-grow-1">
|
||||
<input type="text" class="form-control"
|
||||
placeholder="Search cosplayers…"
|
||||
value="@filterText"
|
||||
@ref="personInputRef"
|
||||
@oninput="OnInput"
|
||||
@onfocus="OnInputFocus"
|
||||
@onblur="OnBlur"
|
||||
@onkeydown="OnKeyDown" />
|
||||
@if (showPersonDropdown) {
|
||||
<div class="dropdown-menu show person-dropdown-menu"
|
||||
@ref="personDropdownRef">
|
||||
@if (isSearching) {
|
||||
<span class="dropdown-item disabled">Searching…</span>
|
||||
} else if (filterText.Length < 2) {
|
||||
<span class="dropdown-item disabled">Type at least 2 characters to search</span>
|
||||
} else if (searchResults is null || searchResults.Count == 0) {
|
||||
<span class="dropdown-item disabled">No matching cosplayers</span>
|
||||
} else {
|
||||
@foreach (var person in searchResults) {
|
||||
<button type="button"
|
||||
class="dropdown-item @(selectedPersonId == person.Id ? "active" : "")"
|
||||
@onclick="() => SelectPerson(person)">
|
||||
@person.Name
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (EditAlbum?.Person != null && selectedPersonId != null) {
|
||||
<button class="btn btn-sm btn-outline-warning flex-shrink-0" @onclick="UnlinkPerson" title="Remove cosplayer assignment">
|
||||
<i class="bi bi-link-45deg"></i> Unlink
|
||||
</button>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Cover Image</label>
|
||||
@@ -36,6 +66,14 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Visibility</label>
|
||||
<select class="form-select" @bind="visibility">
|
||||
<option value="@EVisibility.Public">Public</option>
|
||||
<option value="@EVisibility.Protected">Protected</option>
|
||||
<option value="@EVisibility.Private">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
</Body>
|
||||
<Footer>
|
||||
<button class="btn btn-secondary" @onclick="OnClose">Cancel</button>
|
||||
@@ -63,26 +101,106 @@
|
||||
string title = string.Empty;
|
||||
Guid? selectedPersonId;
|
||||
Guid? coverAssetId;
|
||||
List<PersonPreviewDto>? people;
|
||||
EVisibility visibility = EVisibility.Protected;
|
||||
List<Guid>? albumAssetIds;
|
||||
bool showAssetPicker;
|
||||
bool _personRemoved;
|
||||
string filterText = string.Empty;
|
||||
bool showPersonDropdown;
|
||||
CancellationTokenSource? blurCts;
|
||||
List<PersonPreviewDto>? searchResults;
|
||||
bool isSearching;
|
||||
int searchVersion;
|
||||
ElementReference personInputRef;
|
||||
ElementReference personDropdownRef;
|
||||
bool pendingPosition;
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
if (Show && people == null) {
|
||||
people = await personService.GetAllAsync();
|
||||
}
|
||||
if (Show && EditAlbum != null) {
|
||||
title = EditAlbum.Name;
|
||||
selectedPersonId = EditAlbum.Person;
|
||||
filterText = EditAlbum.PersonName ?? string.Empty;
|
||||
coverAssetId = EditAlbum.CoverAssetId;
|
||||
visibility = EditAlbum.Visibility ?? EVisibility.Protected;
|
||||
} else if (Show && EditAlbum == null) {
|
||||
title = string.Empty;
|
||||
selectedPersonId = null;
|
||||
filterText = string.Empty;
|
||||
coverAssetId = null;
|
||||
visibility = EVisibility.Protected;
|
||||
}
|
||||
_personRemoved = false;
|
||||
albumAssetIds = AlbumAssetIds;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (pendingPosition && showPersonDropdown) {
|
||||
pendingPosition = false;
|
||||
await JSRuntime.InvokeVoidAsync("masonryObserver.positionDropdown", personInputRef, personDropdownRef);
|
||||
}
|
||||
}
|
||||
|
||||
void OnInputFocus() {
|
||||
blurCts?.Cancel();
|
||||
showPersonDropdown = true;
|
||||
pendingPosition = true;
|
||||
if (filterText.Length >= 2) {
|
||||
_ = Search(filterText);
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnInput(ChangeEventArgs e) {
|
||||
filterText = e.Value?.ToString() ?? string.Empty;
|
||||
showPersonDropdown = true;
|
||||
pendingPosition = true;
|
||||
if (filterText.Length >= 2) {
|
||||
await Search(filterText);
|
||||
} else {
|
||||
searchResults = null;
|
||||
isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async Task Search(string query) {
|
||||
var version = ++searchVersion;
|
||||
isSearching = true;
|
||||
searchResults = await personService.GetAllAsync(page: 0, pageSize: 20, search: query);
|
||||
if (version == searchVersion) {
|
||||
isSearching = false;
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
async Task OnBlur() {
|
||||
blurCts?.Cancel();
|
||||
blurCts = new CancellationTokenSource();
|
||||
try {
|
||||
await Task.Delay(150, blurCts.Token);
|
||||
showPersonDropdown = false;
|
||||
} catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
void OnKeyDown(KeyboardEventArgs e) {
|
||||
if (e.Key is "Escape") {
|
||||
showPersonDropdown = false;
|
||||
}
|
||||
}
|
||||
|
||||
void SelectPerson(PersonPreviewDto person) {
|
||||
selectedPersonId = person.Id;
|
||||
filterText = person.Name;
|
||||
showPersonDropdown = false;
|
||||
_personRemoved = false;
|
||||
}
|
||||
|
||||
void UnlinkPerson() {
|
||||
selectedPersonId = null;
|
||||
filterText = string.Empty;
|
||||
showPersonDropdown = false;
|
||||
searchResults = null;
|
||||
_personRemoved = true;
|
||||
}
|
||||
|
||||
void OpenAssetPicker() {
|
||||
showAssetPicker = true;
|
||||
}
|
||||
@@ -100,14 +218,17 @@
|
||||
var dto = new AlbumCreateDto {
|
||||
Name = title,
|
||||
Person = selectedPersonId,
|
||||
CoverAssetId = coverAssetId
|
||||
CoverAssetId = coverAssetId,
|
||||
Visibility = visibility
|
||||
};
|
||||
await albumService.CreateAlbumAsync(dto);
|
||||
} else {
|
||||
var dto = new AlbumUpdateDto {
|
||||
Name = title,
|
||||
Person = selectedPersonId,
|
||||
CoverAssetId = coverAssetId
|
||||
Person = _personRemoved ? null : selectedPersonId,
|
||||
CoverAssetId = coverAssetId,
|
||||
Visibility = visibility,
|
||||
RemovePerson = _personRemoved
|
||||
};
|
||||
await albumService.UpdateAlbumAsync(EditAlbum.Id, dto);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.person-dropdown-menu {
|
||||
display: block;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -19,7 +19,6 @@
|
||||
<div class="@("album-grid " + ViewMode)">
|
||||
@foreach (var album in albums) {
|
||||
<AlbumCard Album="album"
|
||||
OnNavigate="NavigateToAlbum"
|
||||
SelectionMode="SelectionMode"
|
||||
Selected="SelectedIds.Contains(album.Id)"
|
||||
OnToggleSelection="ToggleAlbumSelection"/>
|
||||
@@ -40,9 +39,9 @@
|
||||
[Parameter] public string ViewMode { get; set; } = "masonry";
|
||||
[Parameter] public bool SelectionMode { get; set; }
|
||||
[Parameter] public HashSet<Guid> SelectedIds { get; set; } = [];
|
||||
[Parameter] public EventCallback<Guid> OnNavigateAlbum { get; set; }
|
||||
[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; }
|
||||
|
||||
List<AlbumPreviewDto> albums = [];
|
||||
int currentPage = 0;
|
||||
@@ -73,7 +72,7 @@
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (albums.Count > 0 && dotNetRef == null) {
|
||||
if (!isLoading && (albums.Count > 0 || hasMore) && dotNetRef == null) {
|
||||
dotNetRef = DotNetObjectReference.Create(this);
|
||||
await jsRuntime.InvokeVoidAsync(
|
||||
"albumObserver.observeBottom", sentinelRef, dotNetRef
|
||||
@@ -89,7 +88,9 @@
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var items = await albumService.GetAlbumsAsync(0, 30, SearchQuery, SortBy, SortAsc);
|
||||
var items = FetchAlbums != null
|
||||
? await FetchAlbums(0, 30, SearchQuery, SortBy, SortAsc)
|
||||
: await albumService.GetAlbumsAsync(0, 30, SearchQuery, SortBy, SortAsc);
|
||||
if (items?.Count > 0) {
|
||||
albums = items;
|
||||
currentPage = 1;
|
||||
@@ -104,7 +105,9 @@
|
||||
isLoadingMore = true;
|
||||
StateHasChanged();
|
||||
|
||||
var items = await albumService.GetAlbumsAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
|
||||
var items = FetchAlbums != null
|
||||
? await FetchAlbums(currentPage, 30, SearchQuery, SortBy, SortAsc)
|
||||
: await albumService.GetAlbumsAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
|
||||
if (items?.Count > 0) {
|
||||
albums.AddRange(items);
|
||||
currentPage++;
|
||||
@@ -114,8 +117,6 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
async Task NavigateToAlbum(Guid id) => await OnNavigateAlbum.InvokeAsync(id);
|
||||
|
||||
async Task ToggleAlbumSelection(Guid id) => await OnToggleSelection.InvokeAsync(id);
|
||||
|
||||
async Task DisposeObserver() {
|
||||
@@ -127,4 +128,14 @@
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await DisposeObserver();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the IDs of all currently loaded albums.
|
||||
/// </summary>
|
||||
public List<Guid> GetCurrentAlbumIds() => albums.Select(a => a.Id).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the previews of all currently loaded albums.
|
||||
/// </summary>
|
||||
public List<AlbumPreviewDto> GetCurrentAlbumPreviews() => [.. albums];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
@using Butter.Types
|
||||
|
||||
<ModalFrame Title="@Title" Show="Show" OnClose="HandleCancel" Size="normal">
|
||||
<Body>
|
||||
@if (Body is not null)
|
||||
{
|
||||
@Body
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(Message))
|
||||
{
|
||||
<p class="text-muted mb-0">@Message</p>
|
||||
}
|
||||
</Body>
|
||||
<Footer>
|
||||
<button class="btn btn-secondary" @onclick="HandleCancel">@CancelText</button>
|
||||
<button class="btn @ConfirmButtonClass" @onclick="HandleConfirm">@ConfirmText</button>
|
||||
</Footer>
|
||||
</ModalFrame>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets whether the confirm dialog is visible.
|
||||
/// </summary>
|
||||
[Parameter] public bool Show { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the dialog title.
|
||||
/// </summary>
|
||||
[Parameter] public string Title { get; set; } = "Confirm Deletion";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the confirmation message shown as plain text.
|
||||
/// Ignored when <see cref="Body"/> is provided.
|
||||
/// </summary>
|
||||
[Parameter] public string Message { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets custom body content. Overrides <see cref="Message"/> when set.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? Body { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the confirm button text.
|
||||
/// </summary>
|
||||
[Parameter] public string ConfirmText { get; set; } = "Delete";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the confirm button CSS class (e.g. "btn-danger", "btn-warning").
|
||||
/// </summary>
|
||||
[Parameter] public string ConfirmButtonClass { get; set; } = "btn-danger";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cancel button text.
|
||||
/// </summary>
|
||||
[Parameter] public string CancelText { get; set; } = "Cancel";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the callback invoked when the user confirms.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnConfirm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the callback invoked when the user cancels or dismisses the dialog.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
async Task HandleConfirm()
|
||||
{
|
||||
await OnConfirm.InvokeAsync();
|
||||
}
|
||||
|
||||
async Task HandleCancel()
|
||||
{
|
||||
await OnCancel.InvokeAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@using System.Globalization
|
||||
@implements IAsyncDisposable
|
||||
@@ -21,9 +22,9 @@
|
||||
} else {
|
||||
<div class="cosplayers-grid">
|
||||
@foreach (var person in peopleDict.Values) {
|
||||
<div class="cosplayer-card @(SelectionMode ? "cosplayer-card-select" : "")" @key="person.Id"
|
||||
@onclick="() => HandleCardClick(person.Id)"
|
||||
@onclick:preventDefault="true">
|
||||
<a href="/cosplayer/@person.Id" class="@GetCardClass(person)" @key="person.Id"
|
||||
@onclick="() => HandleCardClick(person.Id)"
|
||||
@onclick:preventDefault="SelectionMode">
|
||||
@if (person.ProfileAssetId.HasValue) {
|
||||
var cx1 = person.ProfileCropX ?? 50;
|
||||
var cy1 = person.ProfileCropY ?? 50;
|
||||
@@ -57,7 +58,7 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -74,7 +75,6 @@
|
||||
[Parameter] public bool SortAsc { get; set; } = true;
|
||||
[Parameter] public bool SelectionMode { get; set; }
|
||||
[Parameter] public HashSet<Guid> SelectedIds { get; set; } = [];
|
||||
[Parameter] public EventCallback<Guid> OnCardClicked { get; set; }
|
||||
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
|
||||
[Parameter] public int LoadVersion { get; set; }
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (peopleDict.Count > 0 && dotNetRef == null) {
|
||||
if (!isLoading && (peopleDict.Count > 0 || hasMore) && dotNetRef == null) {
|
||||
dotNetRef = DotNetObjectReference.Create(this);
|
||||
await jsRuntime.InvokeVoidAsync(
|
||||
"cosplayerObserver.observeBottom", sentinelRef, dotNetRef
|
||||
@@ -161,13 +161,24 @@
|
||||
void HandleCardClick(Guid id) {
|
||||
if (SelectionMode) {
|
||||
OnToggleSelection.InvokeAsync(id);
|
||||
} else {
|
||||
OnCardClicked.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) {
|
||||
if (loginService.CanEdit(person.Id)) {
|
||||
if (person.Visibility.HasValue && person.Visibility.Value != EVisibility.Public)
|
||||
classes.Add("border border-warning border-5");
|
||||
}
|
||||
if (SelectedIds.Contains(person.Id))
|
||||
classes.Add("cosplayer-card-select");
|
||||
}
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
void OnAuthInfoChanged(object? _, AuthInfo? info) {
|
||||
_token = info?.Token ?? string.Empty;
|
||||
}
|
||||
@@ -184,4 +195,14 @@
|
||||
loginService.AuthInfoChanged -= OnAuthInfoChanged;
|
||||
await DisposeObserver();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the IDs of all currently loaded cosplayers.
|
||||
/// </summary>
|
||||
public List<Guid> GetCurrentPersonIds() => [.. peopleDict.Keys];
|
||||
|
||||
/// <summary>
|
||||
/// Returns the previews of all currently loaded cosplayers.
|
||||
/// </summary>
|
||||
public List<PersonPreviewDto> GetCurrentPersonPreviews() => [.. peopleDict.Values];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user