195 Commits
Author SHA1 Message Date
REDCODE f0b67b0404 Merge branch 'develop'
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 7m22s
Build and Push Containers / build-and-push (Lactose) (push) Has been cancelled
2026-07-17 02:13:27 +02:00
REDCODE fb2b02e71e Merge branch 'hotfix/ci-actions' into develop
Build and Push Containers / build-and-push (Lactose) (push) Has been cancelled
Build and Push Containers / build-and-push (MilkStream) (push) Has been cancelled
2026-07-17 02:13:16 +02:00
REDCODE 4658ed1a27 fix: remove gha cache config (cache server unreachable from builder)
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m34s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 2m55s
2026-07-17 02:08:20 +02:00
REDCODE 52c08089b2 fix: match matrix service names to actual directory case (Lactose/MilkStream)
Build and Push Containers / build-and-push (Lactose) (push) Failing after 3m34s
Build and Push Containers / build-and-push (MilkStream) (push) Has been cancelled
2026-07-17 02:04:21 +02:00
REDCODE 111875670b fix: lowercase repository name for Docker compatibility
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m0s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m2s
2026-07-17 02:02:16 +02:00
REDCODE 9c50f84783 fix: sanitize tag names for Docker compatibility (replace / with -)
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m13s
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m11s
2026-07-17 02:00:00 +02:00
REDCODE 593d88d3e3 perf: add BuildKit cache mounts for NuGet packages in Dockerfiles
Build and Push Containers / build-and-push (milkstream) (push) Has been cancelled
Build and Push Containers / build-and-push (lactose) (push) Has been cancelled
2026-07-17 01:58:53 +02:00
REDCODE abef50b010 fix: use explicit domain git.r3d.codes instead of gitea.server_url
Build and Push Containers / build-and-push (milkstream) (push) Has been cancelled
Build and Push Containers / build-and-push (lactose) (push) Has been cancelled
2026-07-17 01:57:29 +02:00
REDCODE ce221428f5 ci: use REGISTRY_TOKEN (PAT) instead of GITEA_TOKEN for registry push
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m18s
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m18s
2026-07-17 01:53:29 +02:00
REDCODE 213c643263 ci: pass SixLaborsLicenseKey as build arg for lactose
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m15s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m16s
2026-07-17 01:48:56 +02:00
REDCODE ecc6e02c78 ci: also run on develop and hotfix/* branches
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m16s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m17s
2026-07-17 01:46:30 +02:00
REDCODE b78c5772b4 ci: also run on push to master 2026-07-17 01:45:55 +02:00
REDCODE 6463df51d1 CI: added sixlabors license key to the docker file for lactose 2026-07-17 01:43:37 +02:00
REDCODE dd2d703fc0 ci: add workflow_dispatch trigger with version input 2026-07-17 01:39:10 +02:00
REDCODE a388887f56 ci: add Gitea Actions workflow to build and push containers on version tags
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m30s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m32s
2026-07-17 00:42:22 +02:00
REDCODE 7d30ea2394 Version 1.0
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m10s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m11s
2026-07-17 00:24:24 +02:00
REDCODE ab420fe167 CI: added build + push to internal registry action 2026-07-17 00:21:49 +02:00
REDCODE 19e177504f Merge pull request 'feat: add ability to add assets to an album from album detail page' (#142) from feat/add-assets-to-album into develop
Reviewed-on: #142
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-16 22:08:43 +00:00
REDCODE f57e2864c5 refactor: replace individual [FromQuery] params with AssetBrowseOptionsDto 2026-07-17 00:07:47 +02:00
REDCODE d89f6f0a21 fix: address PR #142 review issues
- Fix timer leak: implement IDisposable on AlbumAssetPicker
- Remove unnecessary full-table COUNT in GetFolderGroups
- Remove dead DirectoryEntryDto.AssetCount
- Remove unused ExistingAssetIds parameter
- Remove redundant .Distinct() after .Union()
- Extract hardcoded page size 150 to named constant
- Rename AssetSearchOptionsDto.Unassigned to Unlinked for consistency
- BrowseAssets returns 404 for missing folderId instead of 200 empty
2026-07-17 00:00:37 +02:00
REDCODE ca217ddb59 fix: adjust embedded picker viewport height offset 2026-07-16 23:50:20 +02:00
REDCODE e025866357 fix: simplify maintenance page CSS to flex-fill container without vh calc 2026-07-16 23:40:31 +02:00
REDCODE 1449664678 feat: add visibility and create-album actions, fix embedded viewport sizing 2026-07-16 23:35:24 +02:00
REDCODE cf8bee555a revert: change LoginService back to Singleton (DI error was from docker fast mode) 2026-07-16 23:27:45 +02:00
REDCODE 664ac54a7c fix: change LoginService from Singleton to Scoped to match Blazored.LocalStorage lifetime 2026-07-16 23:26:50 +02:00
REDCODE df48dd17f4 fix: inline all markup in .razor template to restore CSS isolation 2026-07-16 23:22:43 +02:00
REDCODE f15fe8f31a fix: add scoped CSS for maintenance page to make embedded picker fill viewport 2026-07-16 23:18:02 +02:00
REDCODE 8589a85625 refactor: rename UnlinkedAssetGroupDto to AssetGroupDto 2026-07-16 23:14:14 +02:00
REDCODE 983cd8baef refactor: add Embedded mode, UnlinkedOnly param, rename methods from Unlinked to generic names 2026-07-16 23:13:18 +02:00
REDCODE f52b376fca fix: restrict maintenance page and nav link to admin only 2026-07-16 23:04:55 +02:00
REDCODE 75e57353e7 feat: add list/grid view toggle, bulk delete in maintenance mode, and /maintenance page 2026-07-16 23:03:37 +02:00
REDCODE eae6152738 fix: filter directory names to only paths containing a slash after prefix, excluding filenames 2026-07-16 22:56:17 +02:00
REDCODE c4788e1572 fix: use parameterized SqlQueryRaw instead of unquoted string.Format to prevent SQL error 2026-07-16 22:51:41 +02:00
REDCODE 1dfa9eaf41 perf: replace in-memory path parsing with SELECT DISTINCT SQL, remove dir count badges 2026-07-16 22:44:20 +02:00
REDCODE 50b5a28841 perf: replace in-memory path parsing with SQL SPLIT_PART aggregation and double-LIKE filter 2026-07-16 22:15:11 +02:00
REDCODE 580cdee67d feat: add collapsible sidebar toggle to AlbumAssetPicker folder browser 2026-07-16 21:50:23 +02:00
REDCODE 8f0267b0f7 feat: redesign AlbumAssetPicker with breadcrumb navigation, directory drill-down, and file browser UX 2026-07-16 21:43:49 +02:00
REDCODE 5d9aa21987 feat: add file-browser BrowseUnlinkedAssets endpoint with directory tree from OriginalPath 2026-07-16 21:43:44 +02:00
REDCODE ad1f9e2222 fix: use OnParametersSetAsync instead of fire-and-forget to ensure Blazor re-renders after init 2026-07-16 21:33:48 +02:00
REDCODE 90c81c2d20 fix: use Albums.Count==0 instead of !Any() for unlinked filter, add trace logging 2026-07-16 21:06:18 +02:00
REDCODE 0cc80a084a fix: handle 204/empty responses in AssetService and always reset isLoading in AlbumAssetPicker 2026-07-16 21:06:14 +02:00
REDCODE ad7c38b20e feat: integrate AlbumAssetPicker into AlbumDetail with Add Assets button and merge handler 2026-07-16 20:55:32 +02:00
REDCODE e0a5bd0ce4 feat: add AlbumAssetPicker modal component with drillable folder sidebar, search, and multi-select 2026-07-16 20:54:29 +02:00
REDCODE e41e7d774f feat: extend AssetService with unassigned/folder/uploader/search params and GetUnlinkedGroupsAsync 2026-07-16 20:53:02 +02:00
REDCODE 34220e93d4 feat: extend AssetController with unlinked-groups endpoint and pass new search/unassigned/folder/uploader params 2026-07-16 20:52:33 +02:00
REDCODE 22706a09cd feat: extend AssetRepository with unassigned, folderId, uploadedBy, search filters and GetUnlinkedGroups 2026-07-16 20:51:34 +02:00
REDCODE 664c1de5da feat: add trigram GIN index on Asset.OriginalFilename for efficient ILike search 2026-07-16 20:50:30 +02:00
REDCODE 081295af86 feat: add UnlinkedAssetGroupDto and extend AssetSearchOptionsDto with unlinked/folder/uploader filters 2026-07-16 20:49:56 +02:00
REDCODE 7dd00c0a5e fix: gate registration behind UserRegistrationEnabled setting
AuthController.Register() never checked the UserRegistrationEnabled
server setting, allowing anyone to register even when disabled.

- Inject ISettingsRepository into AuthController
- Return 403 Forbidden at top of Register() if setting is not 'true'
- Add setup test #3.5 to enable registration for the main test flow

Closes #141
2026-07-16 20:37:50 +02:00
REDCODE 3a195dbc15 chore: Adjusted rate-limits of thumbs, preview and jobs to avoid triggering them during normal usage 2026-07-16 20:09:47 +02:00
REDCODE cce310ee14 Merge pull request 'feat: frontend multi-select enhancements - merge, unlink cosplayer, visibility refactor' (#140) from feat/128-frontend-merge-multi-select into develop
Reviewed-on: #140
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-16 17:54:32 +00:00
REDCODE ada113914d refactor: move Unlink cosplayer button from album header to album edit form 2026-07-16 19:48:55 +02:00
REDCODE a2a25673de refactor: replace inline visibility modal with shared VisibilityModal in Cosplayers.razor 2026-07-16 19:33:09 +02:00
REDCODE 963e897fe5 feat: add Unlink cosplayer button to AlbumDetail.razor 2026-07-16 19:31:39 +02:00
REDCODE f9fe91e975 feat: add Merge Albums button to CosplayerDetail.razor 2026-07-16 19:31:12 +02:00
REDCODE 01fc121bc3 feat: add Merge button to Cosplayers.razor with CosplayerGrid preview access 2026-07-16 19:30:22 +02:00
REDCODE da8a5ad539 feat: add Merge button to Albums.razor with AlbumGrid preview access 2026-07-16 19:29:41 +02:00
REDCODE ff2ed4f7cd feat: add MergeModal shared component for merge destination selection 2026-07-16 19:28:45 +02:00
REDCODE 2c292d2d81 feat: add frontend service methods for merge, bulk update, and bulk delete people 2026-07-16 19:27:48 +02:00
REDCODE f064277c31 Merge pull request 'feat: add rate limiting with configurable buckets' (#139) from feature/rate-limit into develop
Reviewed-on: #139
2026-07-16 17:17:53 +00:00
REDCODE c5ea749517 test: added rate limiting stress test 2026-07-16 19:17:37 +02:00
REDCODE ebe83fe691 chore: use record for config classes 2026-07-16 19:06:35 +02:00
REDCODE ab58ac7dd8 Merge branch 'develop' into feature/rate-limit 2026-07-16 16:56:33 +00:00
REDCODE 17d3f0f2a7 Merge pull request 'feat: backend merge endpoints, bulk visibility fix, and tests' (#138) from feat/backend-merge-endpoints into develop
Reviewed-on: #138
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-16 16:46:52 +00:00
REDCODE 1e2bc83b20 fix: guard against null PersonOwnerId in MergePeople albums query
Replace PersonOwnerId ?? Guid.Empty with explicit null check to
avoid matching albums with no owner if sourceIds contained Guid.Empty.
2026-07-16 18:30:23 +02:00
MrFastwind 58014a37bd feat: apply rate-limit buckets to controllers
- AuthController: auth bucket (10 req/60s) — anti-brute-force
- MediaController.GetImage: media_original bucket (50 req/60s)
- MediaController.GetThumb: media_thumb bucket (200 req/60s)
- MediaController.GetPreview: media_preview bucket (200 req/60s)
- JobsController: jobs bucket (30 req/10s) — quick refresh for long-polling
- All other endpoints: global bucket (100 req/60s) via default
2026-07-16 18:26:39 +02:00
MrFastwind c79ed47e41 feat: add rate limiting with configurable buckets
Add fixed-window rate limiting to Lactose with 6 configurable buckets:
- Global (100 req/60s) for all generic calls
- Auth (10 req/60s) strict anti-brute-force
- MediaOriginal (50 req/60s) for full-size media
- MediaThumb (200 req/60s) for thumbnails
- MediaPreview (200 req/60s) for previews
- Jobs (30 req/10s) quick refresh for long-polling

All buckets configurable via appsettings.json RateLimiting section.
Rejected requests return HTTP 429 with JSON body and Retry-After header.
2026-07-16 18:17:58 +02:00
REDCODE e48da562fa test: flatten test numbering to sequential 1–137
Renumber all test blocks flatly with no gaps or sub-letter suffixes.
Split the remaining multi-request block in the bulk visibility section.
2026-07-16 18:08:17 +02:00
REDCODE b943d2e595 fix: split multi-request test blocks into single-request blocks
REST Client only processes the first request per ### block.
Split 99f into 99f/99f1 and 99i2 into 99i2/99i2b so dynamic
variables set by response handlers are available in subsequent tests.
2026-07-16 18:04:40 +02:00
REDCODE 0950106609 test: add merge and bulk visibility tests to WepApiTest.http
Covers: album merge validation and e2e, person merge validation and e2e,
bulk visibility update (people + assets), authorization gating.
2026-07-16 17:58:14 +02:00
REDCODE 7ba1ab1551 feat: add merge endpoints to AlbumController and PersonController
POST /api/album/merge — Curator/Admin, validates DestinationId not in SourceIds
POST /api/person/merge — Admin/Curator, validates DestinationId not in SourceIds
2026-07-16 17:56:52 +02:00
REDCODE 656e6fa9bf feat: add MergePeople to person repository
Reassigns albums (PersonOwnerId), faces (PersonId), and maintainers
from source people to destination, then hard-deletes source people.
2026-07-16 17:56:24 +02:00
REDCODE 5e1306e466 feat: add MergeAlbums to album repository
Loads destination + source albums with assets, moves all assets
from sources to destination (deduplicated), then hard-deletes sources.
2026-07-16 17:55:46 +02:00
REDCODE 22565c4f92 feat: add AlbumMergeDto and PersonMergeDto
New DTOs for merge endpoints. Both require DestinationId and SourceIds,
with DestinationId validated to not appear in SourceIds by the controller.
2026-07-16 17:55:15 +02:00
REDCODE e62a567e4e fix: apply Visibility and MaintainerUserIds in PersonController.BulkUpdate
The bulk update path was ignoring Visibility and MaintainerUserIds
from PersonUpdateDto, unlike the single-update path.
2026-07-16 17:55:00 +02:00
REDCODE e5ffe86077 feat: add visibility-colored borders to cosplayer cards in selection mode
Adds border-warning for non-Public cosplayers and preserves the
cosplayer-card-select accent outline, matching the AlbumCard pattern.
2026-07-16 17:26:26 +02:00
REDCODE d4a53cb2dc fix(UI): make the delete cosplayer button in cosplayer detail page be just an icon to conform with albums detail page 2026-07-16 16:09:34 +02:00
REDCODE 548b0d5bfd Merge pull request 'feat: add reusable ConfirmDialog component for deletion operations' (#135) from issue-57-confirm-dialog into develop
Reviewed-on: #135
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-16 13:59:26 +00:00
REDCODE 92fb5a0149 fix: add ConfirmDialog to Settings folder deletion flow
FolderBox now delegates deletion to parent via OnDeleteRequested
callback instead of deleting directly. Settings.razor shows a
ConfirmDialog before removing the folder.
2026-07-16 15:11:33 +02:00
REDCODE 6542dcafd6 fix: add ConfirmDialog to Cosplayers bulk delete flow
Adds confirmation before bulk-deleting selected cosplayers.
2026-07-16 15:10:37 +02:00
REDCODE b7690b5828 fix: add ConfirmDialog to Albums bulk delete flow
Adds confirmation before bulk-deleting selected albums.
2026-07-16 15:10:20 +02:00
REDCODE f3f2bdba02 fix: add ConfirmDialog to CosplayerDetail delete flows
Adds confirmation before deleting a cosplayer or bulk-deleting selected
albums. Uses a single ConfirmDialog with dynamic message and stored
callback for either action.
2026-07-16 15:10:03 +02:00
REDCODE 8bf6405c64 fix: add ConfirmDialog to AlbumDetail delete flows
Adds confirmation before deleting an album or bulk-deleting selected
assets. Uses a single ConfirmDialog with dynamic message and stored
callback for either action.
2026-07-16 15:09:36 +02:00
REDCODE c1b1feef4d fix: add ConfirmDialog to User delete user flow
Replaces browser window.confirm() with the reusable ConfirmDialog
component.
2026-07-16 15:09:06 +02:00
REDCODE 0e9820f7c3 fix: add ConfirmDialog to AdminUsers delete user flow
Replaces browser window.confirm() with the reusable ConfirmDialog
component for a consistent, styled confirmation experience.
2026-07-16 15:08:42 +02:00
REDCODE 133c697176 feat: add reusable ConfirmDialog component
A shared confirmation dialog wrapping ModalFrame for delete operations.
Supports plain text Message or custom Body RenderFragment, configurable
confirm button text and styling, and OnConfirm/OnCancel event callbacks.
2026-07-16 15:08:13 +02:00
REDCODE 8f07d7c445 Merge pull request 'feat: bulk cosplayer assignment for albums from multiselection mode' (#134) from issue-122-bulk-assign-cosplayer into develop
Reviewed-on: #134
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-16 11:25:22 +00:00
REDCODE c7e83ed97b feat: bulk cosplayer assignment for albums from multiselect mode
Adds a shared LinkToCosplayerModal component with person typeahead
search and wires it into the albums list page's multiselect topbar.

The backend already supports bulk person assignment via POST /api/album
with BulkDto<AlbumUpdateDto> { Data.Person = ... }, so no backend
changes are needed.

Also adds API tests for the bulk album person assignment flow.

Part of #128, closes #122
2026-07-16 02:59:15 +02:00
REDCODE 7db1738678 fix: soft-delete users in UserController.Delete instead of hard-delete
UserController.Delete was calling userRepository.Delete(user) which performed
a hard delete (context.Users.Remove), violating the project's soft-delete
convention. Changed to set user.DeletedAt = DateTime.UtcNow, matching the
pattern used in AssetController.Delete.

Also removed the now-unused Delete method from IUserRepository and
UserRepository for consistency with AssetRepository (which also has no
Delete method).

Extended REST tests 103-104 to verify deletedAt is set after deletion.

Closes #130
2026-07-16 02:31:52 +02:00
REDCODE 3e099987a3 fix: album detail back button uses history.back() with fallback to /albums
Replaces the hardcoded <a href="/albums"> back link with a button
that calls history.back() via JS interop, falling back to NavigateTo("/albums")
when there is no browser history. Fixes #133.
2026-07-16 02:17:24 +02:00
REDCODE ca50554108 feat: wire up SystemUploaderId setting — seed, UI, and crawl job backfill
- Seed SystemUploaderId in DefaultSettings.json (empty = no default owner)
- Read setting once per crawl and apply UploadedBy on new assets
- Backfill UploadedBy on existing assets that are missing it
- Add UserSelect display type for user-picker setting
- Create SettingUserSelect component with search dropdown
- Add setting to Scanning group in Settings UI
2026-07-16 01:18:46 +02:00
REDCODE 2bfa370989 Merge pull request 'feat: role-visible colored borders, AlbumDetail bulk actions, and cascading visibility (#78)' (#131) from issue-78-role-visible-borders into develop
Reviewed-on: #131
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-15 22:59:59 +00:00
REDCODE dbd5d15fa1 fix: label Delete button and cropper modal overlay
- CosplayerDetail: label desktop Delete button and mobile dropdown as
  'Delete Cosplayer' (was 'Delete' / 'Delete person')
- ProfileCropper: move instruction text into modal footer to prevent
  overlap with Cancel/Save buttons; darken overlay background to
  rgba(0,0,0,0.85) instead of semi-transparent rgba(0,0,0,0.6)
2026-07-16 00:57:30 +02:00
REDCODE b1f32844a9 fix: homepage preview navigation, grid observer crash, and cascade labels
- fix: @for closure bug in Home.razor causing preview to show only previous arrow
- guard NavigatePreview against flatList rebuilds during infinite scroll
- fix IntersectionObserver crash when sentinel not in DOM during reload (CosplayerGrid, AlbumGrid)
- align cascade checkbox labels with actual cascade logic (skips private/deleted)
2026-07-16 00:02:46 +02:00
REDCODE 3bf98cbfed fix: prevent private assets with null UploadedBy leaking to anonymous users
Adds userId.HasValue guard to the private-visibility condition in both
AssetRepository and MediaRepository, preventing the null==null match
when an anonymous visitor (userId=null) encounters an asset with null
UploadedBy.
2026-07-15 23:33:08 +02:00
REDCODE 80c9c8bd6f fix: Cosplayers page bugs
- Restore New Cosplayer button on desktop
- Fix ToggleSelectAll via CosplayerGrid.GetCurrentPersonIds()
- Fix ApplyVisibility wiping data — fetch person first, include all fields
- Add GetCurrentPersonIds() to CosplayerGrid for Select All support
2026-07-15 23:10:23 +02:00
REDCODE 7086fb127f feat: add multiselect editing controls to Cosplayers page
- Visibility modal with cascade to albums and assets
- Desktop inline + mobile dropdown action buttons
- Select All / Deselect All button
- Uses PersonService single-update for visibility, AlbumService cascade
2026-07-15 23:03:02 +02:00
REDCODE 19ab8c0975 refactor: centralize cascade visibility logic in AlbumService.CascadeVisibilityToAssetsAsync
- Add CascadeVisibilityToAssetsAsync to AlbumService (skips private/deleted)
- Replace duplicated loop+filter in Albums, CosplayerDetail, PersonForm with single call
- Update labels to describe skip-private/deleted behavior
2026-07-15 23:01:41 +02:00
REDCODE d7f8350ae7 fix: add cascade checkbox to CosplayerDetail album visibility modal 2026-07-15 22:58:36 +02:00
REDCODE f0e3cc9c37 feat: cascade visibility to albums and assets with lower-only rule
- VisibilityModal: add optional CascadeLabel + CascadeEnabled for checkbox
- Albums page: checkbox to cascade album visibility to non-deleted assets
  (lower-only: never raise asset visibility)
- PersonForm: cascade checkboxes when editing cosplayer visibility
  - Cascade to albums (lower only)
  - Cascade to assets in every album (only if album cascade enabled)
- Inject AssetService in Albums page and PersonForm
2026-07-15 22:41:13 +02:00
REDCODE 4f56af252b refactor: extract VisibilityModal shared component, add editing controls to Albums page
- Create MilkStream.Client/Components/Shared/VisibilityModal.razor
- Replace duplicated modal markup in AlbumDetail, CosplayerDetail, Albums
- Add Select All/Deselect All to Albums via AlbumGrid ref
- Add Delete, Visibility modal to Albums select mode
- Add mobile dropdown to Albums action buttons
- Add GetCurrentAlbumIds() to AlbumGrid for Select All support
2026-07-15 22:31:41 +02:00
REDCODE 8aeb4df064 fix: wrap CosplayerDetail action buttons into mobile dropdown 2026-07-15 22:24:07 +02:00
REDCODE dc2983ae55 fix: album-card-select and cosplayer-card-select only on selected cards 2026-07-15 22:23:01 +02:00
REDCODE a50dc3aa30 fix: prevent jagged wrapping of toolbar and action buttons
SortFilterBar: wrap search+sort in flex-nowrap row, remove ms-auto
from ActionButtons so wrapped items are left-aligned and clean.
AlbumDetail: add flex-shrink: 0 to action buttons container.
2026-07-15 22:19:42 +02:00
REDCODE 3cdad1cebd feat: add album-level bulk actions to CosplayerDetail page
- Add BulkUpdateAlbumsAsync to AlbumService
- Fix AlbumCard border gate from IsAdminOrCurator to CanEdit()
- CosplayerDetail: Delete, Unlink, Visibility buttons in select mode
- CosplayerDetail: Select All / Deselect All button
- CosplayerDetail: visibility breakdown stats in header
- CosplayerDetail: visibility modal for albums
2026-07-15 22:14:18 +02:00
REDCODE 0903da4490 fix: show borders for maintainers in select mode 2026-07-15 22:07:38 +02:00
REDCODE 2fcd6467e1 feat: add Select All / Deselect All button in album select mode 2026-07-15 22:05:28 +02:00
REDCODE 10337c735c feat: show visibility/deleted breakdown in album header in select mode 2026-07-15 21:59:27 +02:00
REDCODE e3d4cf9673 fix: update border colors and browse-mode visibility in AlbumDetail
- Private assets → red border (instead of orange)
- Protected assets → orange border
- Deleted assets → greyed out (opacity + grayscale)
- When not in select mode: hide Private and Deleted assets from view
2026-07-15 21:55:54 +02:00
REDCODE a203885377 Merge branch 'issue-78-album-detail-bulk-actions' into issue-78-role-visible-borders
Adds bulk delete, visibility modal, and rename Remove→Unlink to
AlbumDetail select mode, plus bugfix for missing Save() in bulk endpoints.
2026-07-15 21:48:18 +02:00
REDCODE fff5ea65df feat: add bulk delete and visibility change to AlbumDetail select mode
- Fix AssetController.BulkUpdate/BulkDelete — add missing Save() call
- Add BulkUpdateAssetsAsync and BulkDeleteAssetsAsync to AssetService
- Rename 'Remove N' → 'Unlink N' (removes from album, original behavior)
- Add 'Delete N' button — soft-deletes selected assets via BulkDelete
- Add 'Visibility' button — opens ModalFrame to pick Public/Protected/Private
- Apply calls BulkUpdateAssetsAsync with chosen visibility level
2026-07-15 21:46:28 +02:00
REDCODE 86f95d7461 feat: add role-visible colored borders on albums and assets based on item state (#78)
Backend:
- Add DeletedAt to AssetPreviewDto, AlbumAssetPreviewDto, AlbumPreviewDto
- Remove redundant DeletedAt from AssetDto (now inherited from base)
- Add viewerId parameter to ToAssetPreviewDto, ToAlbumPreviewDto,
  ToAlbumFullDto, ToPersonDetailedDto mappers
- Conditionally send DeletedAt only when viewer is Admin or asset uploader
- Pass uid/viewerId from all controller/repository call sites

Frontend:
- AlbumCard: show orange (not public) / red (deleted) border in select
  mode for admins/curators
- AlbumDetail: same border logic in GetTileClass for asset tiles
- Borders only appear in select mode per REDCODE's feedback
2026-07-15 20:48:00 +02:00
REDCODE ec287f087c docs: add visibility rules (R1–R7) to AGENTS.md 2026-07-15 20:32:23 +02:00
REDCODE 758696f7c9 fix: add entity-level visibility gating for albums and persons
Adds R3/R4 visibility gates to AlbumRepository.FindVisible and
PersonRepository.FindVisible so that 404 is returned when the
requesting user lacks permission to see the entity itself.

Adds comprehensive HTTP tests verifying:
- R2: anonymous asset detail access
- R3: Public/Protected visible to User, Private returns 404 (Admin sees all)
- R4: Private person returns 404 for User, 200 for Admin/Curator
2026-07-15 20:30:06 +02:00
REDCODE de9cd618b6 docs: fix stale column references and tooling versions in AGENTS.md
- Update dotnet-tools.json location reference
- Fix EF Core Tools version (both now v10.0.9)
- Remove stale SharedWith.Any() EF Core gotcha (per-user sharing removed)
- Fix index example to use Visibility/UploadedBy columns
2026-07-15 18:41:37 +02:00
REDCODE e7fb01be9f Merge pull request 'feat: frontend alignment for #106 auth redesign — Maintainer role, visibility UI, user data' (#127) from feature/106-auth-redesign-frontend into develop
Reviewed-on: #127
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-15 16:23:17 +00:00
REDCODE e4a34a7dad fix: show maintained cosplayers section for all user types in user profile edit modal
- Remove _editAccessLevel == Maintainer guard so the maintainer
  assignment section appears when editing any user as admin
- OpenEditModal already loads maintained persons regardless of access level
2026-07-15 17:42:31 +02:00
REDCODE d799c3eb1f docs: clarify Curators and Admins can be maintainers for credited attribution
- Update Actors table: Curator and Admin entries mention maintainer assignment
- Update Role Design Philosophy: same clarification
- Update Curator section: note about PersonMaintainer as credit/attribution
- Update Admin section: same note
2026-07-15 17:40:49 +02:00
REDCODE c5fabf0e07 fix: show maintained cosplayers section for all user types in admin edit modal
- Remove _editAccessLevel == Maintainer guard from the maintained
  cosplayers markup so it appears for User, Curator, and Admin edits too
- Remove the same guard from OpenEditModal so maintained persons are
  loaded regardless of the user's current access level
2026-07-15 17:39:36 +02:00
REDCODE 3cdf9eb185 refactor: extract person search/assign UI into shared SearchAssignBadges component
- Create generic SearchAssignBadges component (TItem parameterized) with
  search input, dropdown results, and removable badge chips
- Replace duplicate markup in AdminUsers.razor, User.razor, PersonForm.razor
- Update RemoveAssignedPerson/RemoveMaintainer to accept the item DTO
  instead of Guid, aligning with the component's EventCallback<TItem> pattern
2026-07-15 17:33:26 +02:00
REDCODE 6efccae030 fix: address PR #127 review issues — fallback, eager load, indentation, Maintainer tests
- Change ?? EAccessLevel.Admin fallback to ?? EAccessLevel.User in UserController.GetAll
- Add .Include(u => u.MaintainedPersons) to UserRepository.GetAll() so MaintainedPersonIds/MaintainedPersonNames populate in list endpoint
- Fix UsersMapper.cs method body indentation (was flush with class)
- Add Maintainer-role visibility tests (68.5-68.8): promote user, test viewing admin, demote back
2026-07-15 17:31:33 +02:00
REDCODE 93cbbea3c7 fix: allow maintainer assignment for all user roles in UI
Remove _editAccessLevel == Maintainer guard so admins can assign
maintainers to curators/admins/users, not just Maintainer-level users.
2026-07-15 15:12:13 +02:00
REDCODE cace8640e9 fix: replace remaining jsonPath calls in asset visibility tests
Test 75 admin assertion: allow null email for server-scanned assets
(Guid.Empty placeholder has no email). Removes all remaining jsonPath
calls that could NPE on null resolved values.
2026-07-15 15:03:50 +02:00
REDCODE a3ce98a984 fix: replace jsonPath with response.body for null-value checks in visibility tests
jsonPath throws NullPointerException in GraalVM when the resolved JSON
value is null. Use response.body[n].property directly for all null
assertions in search-list and visibility tests.
2026-07-15 14:55:27 +02:00
REDCODE 4f3e3a28cc fix: enforce data visibility per access level with test coverage
- UsersMapper.ToGetUsersDto: filter Email, BannedAt, DeletedAt, AccessLevel,
  MaintainedPersonIds by viewer access (admin/self only)
- AssetsMapper.ToFullAssetsDto: pass viewerId to restrict UploadedBy.Email;
  null-safe Uploader fallback
- AssetsMapper.ToAssetPreviewDto: gate FileName behind Curator+ (was public)
- AlbumMapper: gate FileName behind Curator+; fix AssetCount to count only
  non-deleted assets
- AssetRepository.FindVisible: include Uploader to prevent NRE (500 error)
- AssetController: restrict UploadedBy reassignment to Admin only
- UserController: pass viewer context to all ToGetUsersDto calls
- WepApiTest.http: add 18 data visibility tests (#64-#81) verifying field
  gating per role; fix 3 pre-existing stale assertions; avoid jsonPath NPE
  on null values by using response.body directly
2026-07-15 14:53:39 +02:00
REDCODE 657ffaae11 fix: show email to self-viewing users on user page 2026-07-15 14:24:41 +02:00
REDCODE 700d5a63a7 chore: fix Dockerfiles, add debug compose to solution 2026-07-15 14:21:18 +02:00
REDCODE 9531ac2e23 fix: NRE on user page when user data not yet loaded
Add loading spinner and null-state guard to prevent rendering
before user data arrives. Fix unsafe `user is not {DeletedAt: null}`
pattern that throws NRE on null user.
2026-07-15 14:19:13 +02:00
REDCODE 288da412d9 fix: remove underline from maintainer links, load assigned person names in user edit modal 2026-07-15 14:11:57 +02:00
REDCODE 1b34520a66 feat: add docker-compose.debug.yml and UseWebAssemblyDebugging for WASM debugging
- Add docker-compose.debug.yml override for Debug build + Development env
- Add UseWebAssemblyDebugging() middleware in Development
- Production path unchanged (HSTS + HTTPS redirect preserved)
2026-07-15 14:10:25 +02:00
REDCODE 23b285cf49 fix: remove underline from user links, hide email from non-admin viewers 2026-07-15 13:18:32 +02:00
REDCODE 27707b120c feat: make usernames clickable on admin users page, linking to /User/{id} 2026-07-15 13:17:37 +02:00
REDCODE 14978087e3 feat: expand user page edit modal for admins with access level, maintainer assignment, and danger zone 2026-07-15 13:16:32 +02:00
REDCODE 3021788271 fix: null reference in User page maintainer display 2026-07-15 13:14:28 +02:00
REDCODE 358bf0918e feat: maintainer assignment UI for cosplayers, maintainer display on user page
Backend:
- Add MaintainerUserIds to PersonUpdateDto/PersonDetailedDto
- Add MaintainedPersonNames to UserInfoDto
- Add IPersonRepository.SetMaintainers + implementation
- PersonRepository.Find/FindVisible now include Maintainers
- PersonController.Update handles maintainer assignment (admin/curator)
- Fix PersonController.Update to save Visibility field
- Allow curators to list all users via UserController.GetAll

Frontend (PersonForm):
- Add maintainer search+select UI (admin/curator only in edit mode)
- Search users client-side, click to add, badge with X to remove
- Saves maintainer IDs on person update

Frontend (CosplayerDetail):
- Show 'Maintained by: user1, user2' in header with clickable links
- Pass maintainer data to PersonForm

Frontend (User page):
- Show 'Maintainer of: cosplayer1, cosplayer2' with links
- Non-admin view: strip Email, Created, Deleted fields
- Fix LoadUser to use route UserId instead of logged-in user
2026-07-15 13:11:05 +02:00
REDCODE 656784af00 feat: maintainer cosplayer assignment in admin user edit modal
Backend:
- Add MaintainedPersonIds to UserUpdateDto
- Add SetMaintainedPersons to user repository (removes old PersonMaintainer rows, inserts new)
- Wire up in UserController.Update (admin only)

Frontend:
- Add PersonService injection to AdminUsers page
- Searchable cosplayer dropdown in edit modal for Maintainer users
- Click result to add, badge with X to remove
- Zero debounce, saves only on Submit
- Pre-loads currently assigned cosplayer names on modal open
2026-07-15 12:55:03 +02:00
REDCODE c8b71f290b fix: add missing app.css link to index.html 2026-07-15 12:27:38 +02:00
REDCODE 8f109bf92f chore: commit compiled CSS for role badge classes 2026-07-15 12:19:46 +02:00
REDCODE 268b380867 refactor: move CanEdit logic to LoginService, remove local page properties 2026-07-15 12:18:53 +02:00
REDCODE 71889ddfac refactor: extract role checks to LoginService, update all consumers 2026-07-15 12:06:05 +02:00
REDCODE 0a74ff8770 refactor: generate access level dropdown from enum values 2026-07-15 12:04:35 +02:00
REDCODE f158181ccf refactor: extract role badge colors to SCSS theme layer 2026-07-15 12:04:01 +02:00
REDCODE e585abc378 feat: expand UI gating to include Maintainer with scope-aware CanEdit 2026-07-15 11:26:43 +02:00
REDCODE ccf16b6abc feat: add Protected Assets card to stats page 2026-07-15 11:25:05 +02:00
REDCODE 7dcb78db97 feat: add Visibility dropdown to album and person create/edit forms 2026-07-15 11:24:18 +02:00
REDCODE 26984f4452 feat: add Maintainer to admin user dropdowns and role badges 2026-07-15 11:23:15 +02:00
REDCODE f32d0678df feat: add MaintainedPersonIds to UserInfoDto with backend plumbing 2026-07-15 11:22:33 +02:00
REDCODE 05b99ab6c5 Merge pull request 'feat: #106 auth/permissions redesign — 5-actor model, #93 visibility levels (backend)' (#125) from feature/106-auth-redesign into develop
Reviewed-on: #125
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-14 20:23:23 +00:00
REDCODE 88a0098314 perf: add AsSplitQuery to repository queries with multiple Include calls 2026-07-14 22:00:33 +02:00
REDCODE 8b53899648 docs: add data size estimates to AGENTS.md 2026-07-14 21:46:19 +02:00
REDCODE e2b0eaba37 refactor: rename AlbumSearchParametersDto.UploadedBy to AssetUploadedBy
Clarifies that this property filters albums by assets uploaded by a user,
not albums themselves being uploaded by a user.
2026-07-14 21:30:14 +02:00
REDCODE e1504dbf72 style: standardize access level comparison to switch expressions
- AlbumRepository.SearchQuery: if/else → switch expression
- PersonRepository.FindVisible: nested if → switch statement with guarded case
- AlbumController Update/BulkDelete/Delete: if/else → switch
2026-07-14 21:29:46 +02:00
REDCODE b67b2260fe docs(repo): fix stale pageSize default in IAlbumRepository XML doc
Actual default is PagedParametersDto.MaxPageSize (changed in 08c3890),
but documentation still referenced the old hardcoded 150.
2026-07-14 21:27:15 +02:00
REDCODE 06fd26e641 refactor: move all visibility filtering from mappers and controllers to repositories
- AlbumMapper: remove CountVisibleAssets(), drop userId param from ToAlbumPreviewDto()
- PersonMapper: remove IsAssetCountVisible(), drop userId param from ToPersonDetailedDto()
- AssetRepository: add FindVisible(), add userId/accessLevel params to GetAssets()
- AlbumRepository: add FindVisible(), replace .Include(a => a.Assets) with subquery count in SearchQuery()
- PersonRepository: add FindVisible() with visibility filtering + album search/sort/pagination
- AssetController.Get: replace inline switch with FindVisible()
- AssetController.GetAll: pass uid/accessLevel to GetAssets(), remove inline filter
- AlbumController.Get: replace inline asset filter with FindVisible()
- PersonController.Get: replace 50-line inline filter/search/sort/page with FindVisible()
2026-07-14 21:17:02 +02:00
REDCODE 74c74cbbdf fix(mapper): scope asset counts in album/ person previews to visible assets
- Added optional userId parameter to ToAlbumPreviewDto and ToPersonDetailedDto
- AlbumRepository.SearchQuery and PersonMapper now pass userId for visibility-scoped counts
- ToAlbumFullDto unchanged (callers pre-filter assets before mapping)
- Private helper filters out non-visible assets for non-curator users
2026-07-14 20:44:52 +02:00
REDCODE 774ee0d7c7 fix(media): allow Maintainer to access media files for cosplayers they maintain
- Injected IPersonRepository into MediaRepository
- Updated GetVisibleAsset with Maintainer-specific check via album→person→maintainer chain
2026-07-14 20:42:55 +02:00
REDCODE 45b3c56ce2 fix(controller): split Maintainer from User in AssetController.GetAll visibility filter
Maintainers now also see Private assets belonging to cosplayers they maintain,
not just their own uploads.
2026-07-14 20:42:26 +02:00
REDCODE b0fa104642 fix(mapper): replace null-forgiving operator with null-conditional in AlbumMapper
- ToAlbumFullDto.AssetPreviews and Images now use ?. instead of !.
- Falls back to empty list when Assets is null, matching ToAlbumPreviewDto pattern
2026-07-14 20:41:16 +02:00
REDCODE d4cd305c28 fix(controller): scope AssetController maintainer permissions via album→person chain
- Added IPersonRepository injection into AssetController
- Added FindWithAlbums to IAssetRepository/AssetRepository for single-entity checks
- Modified FindBulk to include Albums navigation property
- Single Update/Delete: Maintainers can act on assets in albums of maintained persons
- BulkUpdate/BulkDelete: same scope expansion for Maintainers
2026-07-14 20:35:55 +02:00
REDCODE 671783fd16 fix(repo): scope AlbumRepository.SearchQuery maintainer visibility to maintained persons
Maintainers now only see Private albums for persons they maintain,
matching the same pattern already used in PersonRepository.SearchQuery.
2026-07-14 20:33:38 +02:00
REDCODE 674710986f fix(model): add [PrimaryKey] annotation to PersonMaintainer for clarity
Composite key (PersonId, UserId) was only defined via Fluent API;
[PrimaryKey] makes the model class self-documenting.
2026-07-14 20:33:18 +02:00
REDCODE 77a6ee9747 fix(dto): make AssetCreateDto.UploadedBy nullable for scanner-created assets
- Changed UploadedBy from Guid to Guid? to represent system/scanner uploader
- Controller Create method now handles null UploadedBy (skips user validation)
2026-07-14 20:18:53 +02:00
REDCODE 1f2bf02d02 fix(migration): default Visibility to Protected instead of Public, preserve old IsPubliclyShared mapping
- Changed column default from 0 (Public) to 1 (Protected) for Assets, People, Albums
- Reordered migration to add Visibility column before dropping IsPubliclyShared
- Assets that were IsPubliclyShared = true are restored to Public (0) via SQL
- Private assets and all existing people/albums default to Protected (1)
2026-07-14 20:18:13 +02:00
REDCODE c5b8c93a39 fix(migration): remap existing EAccessLevel values to prevent data corruption
Old enum values stored in DB:
  - Curator (value 1) → Maintainer after renumbering (incorrect)
  - Admin   (value 2) → Curator after renumbering (incorrect)

Add SQL UPDATE statements to remap:
  1. Old Admin (2) → New Admin (3) — done first to free up value 2
  2. Old Curator (1) → New Curator (2) — done second
2026-07-14 20:17:32 +02:00
REDCODE b9251c3e19 style(repo): use explicit named enum cases in PersonRepository switch expressions 2026-07-14 19:22:14 +02:00
REDCODE 0d1ce9feb3 style(repo): expand switch expression arms for readability 2026-07-14 19:14:35 +02:00
REDCODE 0673017765 style(repo): convert visibility filters to switch expressions for readability 2026-07-14 19:13:20 +02:00
REDCODE 08c389026a fix(repo): use PagedParametersDto.MaxPageSize const instead of hardcoded 150
Replaced magic number with named constant in both interface and implementation.
2026-07-14 19:03:02 +02:00
REDCODE bbb2ed8f73 fix(dto): default AssetCreateDto.Visibility to Private instead of Protected 2026-07-14 19:02:34 +02:00
REDCODE fe33fe2044 fix(stats): add ProtectedAssets count, fix PrivateAssets to count only Private
Previously PrivateAssets counted everything != Public (i.e., Protected+Private combined).
Now PublicAssets=Public, ProtectedAssets=Protected, PrivateAssets=Private.
2026-07-14 19:02:07 +02:00
REDCODE a449c792c1 fix(media): replace bool parameter with EAccessLevel, add deleted asset check
- GetVisibleAsset: accept EAccessLevel enum instead of bool isCuratorOrAbove
- Admin: sees all assets including deleted
- Curator: sees own deleted + all non-deleted
- Maintainer/User: only non-deleted + visibility filtering
2026-07-14 19:01:39 +02:00
REDCODE 9323111d0d fix(mapper): allow Maintainer to see Visibility field in DTO responses
Changed threshold from >= Curator to >= Maintainer in all three mappers (AssetsMapper, PersonMapper, AlbumMapper). FileName stays Curator+ only.
2026-07-14 19:00:57 +02:00
REDCODE c2326414b4 fix(controller): add Maintainer scope to Album BulkUpdate, Delete, BulkDelete
- BulkUpdate: Maintainer can update albums of persons they maintain
- Delete: Maintainer can delete albums of persons they maintain
- BulkDelete: same scoped filtering
2026-07-14 19:00:07 +02:00
REDCODE 747ad77f4c fix(controller): scope Maintainer asset access to only maintained cosplayers in Get
Maintainer of the album's person sees all non-deleted assets. Otherwise applies User-level visibility filter.
2026-07-14 18:59:23 +02:00
REDCODE f77a4574ac fix(controller): scope Maintainer album visibility to only maintained persons
When viewing a person they maintain, show all albums. Otherwise apply User-level visibility filter (Public + Protected only).
2026-07-14 18:58:47 +02:00
REDCODE c64c1947e6 fix(repo): scope Maintainer to only maintained persons in GetAllVisible and SearchQuery
Previously Maintainer saw all people like Curator+. Now:
- Curator+: sees all people
- Maintainer: sees Public + Protected + persons they maintain
- User: sees Public + Protected only
2026-07-14 18:58:14 +02:00
REDCODE 1b444dab93 feat(auth): complete Maintainer role — add PersonMaintainer checks, update controller authz, pass uploadedBy search param
- AlbumSearchParametersDto: add UploadedBy field
- IPersonRepository/PersonRepository: add IsMaintainerOf method
- AlbumController: allow Maintainer to update with scope check, pass uploadedBy to SearchQuery
- PersonController: allow Maintainer to update maintained persons
- AssetController: allow Maintainer to update/delete own assets
- TagController: allow Maintainer to CRUD all tags
- Fix Authorize(Roles) to include Maintainer where appropriate
- Update WepApiTest.http: curator accessLevel 1→2
2026-07-14 18:13:15 +02:00
REDCODE 50bf0a2a78 feat(db): add migration for auth redesign — drop IsPubliclyShared/UserOwnerId/SharedWith, add Visibility, PersonMaintainer, rename OwnerId→UploadedBy 2026-07-14 17:45:22 +02:00
REDCODE 2335e8e23a feat(repos,controllers): update repositories, interfaces, controllers for auth redesign — visibility filtering, UploadedBy/Uploader rename, Maintainer support
- PersonRepository, AlbumRepository, AssetRepository, MediaRepository, StatsRepository: visibility-based filtering
- IAlbumRepository, IAssetRepository, IMediaRepository: updated signatures (FindByUploader, uploadedBy param, accessLevel)
- AlbumController, AssetController, PersonController, MediaController: replace IsPubliclyShared/SharedWith/UserOwnerId/Owner with Visibility/UploadedBy/Uploader
- FileSystemCrawlJob: IsPubliclyShared=false → Visibility=EVisibility.Private
- Asset model: nav property UploadedBy→Uploader to avoid FK/nav name collision
2026-07-14 17:44:46 +02:00
REDCODE d02a010083 feat(infra): update DbContext and mappers for auth redesign — new relationships, PersonMaintainer, conditional Visibility 2026-07-14 17:40:34 +02:00
REDCODE 6676347353 feat(models): update models for auth redesign — rename Owner→UploadedBy, drop UserOwnerId/IsPubliclyShared, add EVisibility, PersonMaintainer 2026-07-14 17:39:48 +02:00
REDCODE c9391d840b feat(butter): update DTOs for visibility, renamed Owner→UploadedBy, removed IsPublic/Owner fields 2026-07-14 17:39:01 +02:00
REDCODE 51323b4a64 feat(butter): add EVisibility enum, Maintainer access level, and SystemUploaderId setting 2026-07-14 17:37:42 +02:00
REDCODE 842ed76345 fix(jobs): use grey for queued segments in layered progress bar 2026-07-14 13:14:44 +02:00
REDCODE f3ed658dd0 chore: Updated packages, removed unused packages. ImageSharp now requires a license 2026-07-14 13:14:27 +02:00
REDCODE 6c83b8395b fix(album-form): searchable person combobox with modal-safe positioning
Replaces the static <select> with a server-side searchable combobox
in the album edit modal. Fixes issues where the person dropdown was
empty and the name field was not pre-filled on edit.

- Server-side person search (trigram-indexed ILIKE, 20 results)
- 2-character minimum before search triggers
- pre-populates input with current person name when editing
- position: fixed via JS to escape modal-body overflow clipping

Closes #123
2026-07-14 12:43:59 +02:00
REDCODE e58524ce19 Merge pull request 'refactor: reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate' (#121) from refactor/120-albumgrid-cosplayerdetail into develop
Reviewed-on: #121
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-14 10:22:09 +00:00
REDCODE 9dced21554 docs: reconcile AGENTS.md with current codebase
- Remove stale Npgsql legacy timestamp switch note (removed from code)
- Fix EF Core version mismatch description (Tools 8.0.15, not CLI)
- Expand analyzer rules to cover MS001–MS004
2026-07-14 12:09:33 +02:00
REDCODE 3fdf55b53c docs: add PR workflow section to AGENTS.md 2026-07-14 12:07:20 +02:00
REDCODE f5b28e5a21 docs: add Gitea label levels note to AGENTS.md 2026-07-14 12:04:49 +02:00
REDCODE 7a380d7df5 refactor: reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate
Replace manual album state management (inline AlbumCard loop, own
infinite scroll, own sentinel/observer, _ = ReloadAlbums() discard
lambdas) with <AlbumGrid> using a new FetchAlbums delegate parameter.

- AlbumGrid.razor: add FetchAlbums Func parameter; use it in
  Reload()/LoadMoreAlbums() when set
- CosplayerDetail.razor: replace manual album rendering + state with
  <AlbumGrid FetchAlbums=@FetchPersonAlbums />; remove all album
  state management fields (allAlbumsDict, currentAlbumPage, etc.),
  ReloadAlbums/LoadMoreAlbums/OnAfterRenderAsync methods, and
  IAsyncDisposable; SortFilterBar now uses clean lambdas
- CosplayerDetail.razor.css: remove unused album-grid overrides

Closes #120
2026-07-14 12:00:38 +02:00
REDCODE f7d6348ea9 Merge pull request 'fix: make card components middle-clickable by using <a> instead of <div @onclick>' (#119) from fix/102-card-middle-click into develop
Reviewed-on: #119
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-14 09:57:32 +00:00
REDCODE 46871299ca fix: remove underline from album card person link 2026-07-14 11:51:58 +02:00
REDCODE c281746fdc fix: make card components middle-clickable
Convert <div @onclick> -> NavigationManager.NavigateTo() to real <a>
elements so cards support middle-click, Ctrl+click, and
'Open in new tab'.

- CosplayerGrid.razor: replace <div> with <a href='/cosplayer/{id}'>
- AlbumCard.razor: add full-card <a> overlay, keep inner person <a>
- CosplayerGrid.razor.css + AlbumCard.razor.css: add display:block,
  color:inherit, text-decoration:none for <a> card elements
- Remove OnCardClicked/OnNavigate callbacks and parent page
  navigation methods (nav is now native via <a href>)

Closes #102
2026-07-14 11:46:13 +02:00
REDCODE be7532dd74 fix: implement CosplayersMissingProfile stat
Replaces the hardcoded zero with a real query counting people
without a profile picture (ProfileAssetId == null).

Renamed from CosplayersMissingCover to CosplayersMissingProfile
to reflect the actual metric.

DTO, repository, and UI label updated accordingly.

fixes #97
2026-07-14 11:40:06 +02:00
123 changed files with 8258 additions and 1071 deletions
+67
View File
@@ -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 }}
+2
View File
@@ -8,3 +8,5 @@ storageImages/
dotnet-tools.json
.opencode/
*.css.map
# SixLabors license file
/Lactose/sixlabors.lic
+80 -9
View File
@@ -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 (R1R7)
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 (R2R4). 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`).
+16 -6
View File
@@ -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; }
}
+10 -8
View File
@@ -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;
}
+18
View File
@@ -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; } = [];
}
+16 -10
View File
@@ -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; }
}
+11 -9
View File
@@ -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;
}
+23
View File
@@ -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; }
}
+13 -12
View File
@@ -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; }
}
+9 -17
View File
@@ -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; }
}
+20
View File
@@ -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; }
}
+21 -11
View File
@@ -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; }
}
+7 -5
View File
@@ -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; }
}
+11
View File
@@ -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;
}
+11 -5
View File
@@ -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;
}
+13
View File
@@ -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; }
}
+18
View File
@@ -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; } = [];
}
+13 -7
View File
@@ -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; }
}
+15 -5
View File
@@ -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; }
}
+7 -4
View File
@@ -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.
+9
View File
@@ -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; }
}
+4
View File
@@ -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; }
}
+2
View File
@@ -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>
+4 -1
View File
@@ -31,7 +31,9 @@ public enum Settings {
/// <summary>Quality setting (1100) for WebP thumbnail encoding.</summary>
ThumbnailQuality,
/// <summary>Quality setting (1100) 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 -3
View File
@@ -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
}
+13
View File
@@ -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
}
+3 -3
View File
@@ -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);
}
}
+16 -6
View File
@@ -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);
}
+61 -56
View File
@@ -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();
+110 -89
View File
@@ -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;
}
+11 -2
View File
@@ -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:
+2
View File
@@ -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>
+7 -3
View File
@@ -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);
+45 -55
View File
@@ -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>
+5 -5
View File
@@ -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) {
+14 -8
View File
@@ -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();
+7
View File
@@ -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
View File
@@ -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
+22 -3
View File
@@ -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 {
+3 -5
View File
@@ -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>
+19 -34
View File
@@ -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() ?? []
};
}
+25 -16
View File
@@ -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));
}
+13 -14
View File
@@ -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
};
}
+24 -12
View File
@@ -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
};
}
}
@@ -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
View File
@@ -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
View File
@@ -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.
+11
View File
@@ -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
}
+32
View File
@@ -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!;
}
+4 -9
View File
@@ -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
}
+66
View File
@@ -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();
+115 -16
View File
@@ -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);
+223 -5
View File
@@ -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();
}
}
+18 -6
View File
@@ -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);
}
+56 -7
View File
@@ -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);
}
+7 -3
View File
@@ -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);
}
+42 -4
View File
@@ -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);
}
+12 -3
View File
@@ -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);
}
+47 -19
View File
@@ -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",
+183 -48
View File
@@ -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();
}
+4 -3
View File
@@ -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")
+13 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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
}
}
}
+4 -2
View File
@@ -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()
+1 -1
View File
@@ -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);
+202 -27
View File
@@ -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++;
}
+15 -5
View File
@@ -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>
+323 -67
View File
@@ -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">&times;</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