43 Commits
Author SHA1 Message Date
REDCODE ee07306a4d Merge branch 'develop'
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m29s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 2m52s
2026-07-22 15:13:01 +02:00
REDCODE 294f7a6cae chore: removed outdated agent.md 2026-07-22 15:11:31 +02:00
REDCODE 595399385b chore: scripts and run configs to automate version injection 2026-07-22 15:09:52 +02:00
REDCODEandopencode 77ee80cd63 feat: embed git version in WASM footer via AssemblyInformationalVersion
Inject git describe output into the assembly's InformationalVersion at build
time via an MSBuild target (local dev) or Docker build arg (CI/Docker).

Local builds use v1.0.2-21-g9e67c59-dirty; Docker builds take
APP_VERSION as a build arg to avoid MSBuild property collision with VERSION.

Co-authored-by: opencode <opencode@anomaly.co>
2026-07-22 15:09:06 +02:00
REDCODE 9e67c597ef refactor: replace IntersectionObserver with input-event-based infinite scroll
- Remove IntersectionObserver, sentinel elements, and scroll reattach
  from albumObserver and cosplayerObserver
- Add wheel, keydown, and touch event listeners that react only to
  explicit user input commands (scroll down/up, arrow keys, swipe)
- Check page position (isAtBottom/isAtTop) before invoking load
- No observer to get stuck, no cascade from auto-scroll/load completion
2026-07-22 14:31:08 +02:00
REDCODE 05ff6e8f2d fix: prevent infinite scroll cascade by debouncing observer reattach and disabling scroll anchoring
- Replace per-frame scroll reattach with 300ms debounced scroll-end
  reattach to prevent cascade during fast/momentum scrolling
- Add overflow-anchor: none to grid containers and sentinels to
  prevent browsers from auto-scrolling when content is added below
2026-07-22 14:08:29 +02:00
REDCODE 07e43b5023 feat: add sliding-window page buffer to AlbumGrid and CosplayerGrid with scroll-based observer reattach
- Add PageBuffer<T> utility class (capped at 10 pages, drops oldest) to keep
  DOM and memory bounded during infinite scroll
- Refactor AlbumGrid and CosplayerGrid to use PageBuffer instead of unbounded
  List/Dictionary, add top sentinel for bi-directional scroll-back loading
- Add observeTop and scroll-based sentinel reattach to albumObserver and
  cosplayerObserver in masonryObserver.js — prevents IntersectionObserver
  getting stuck when sentinel stays visible after load by re-evaluating on
  each scroll frame
2026-07-22 13:58:30 +02:00
REDCODE 00e0dd42d1 fix: masonry items stacking on infinite scroll — missing @ on ViewMode binding (#146)
Root cause: ViewMode="ViewMode" (no @ prefix) was interpreted as string
literal "ViewMode" by Blazor, not property value "masonry". GetCardStyle()
never produced aspect-ratio, so off-screen lazy-loaded images had 0 height
and _doApply stacked them 8px apart in one column.

Fixes:
- AlbumGrid.razor: ViewMode="ViewMode" -> ViewMode="@ViewMode"
- AlbumCard.razor: GetCardStyle() always returns aspect-ratio for masonry
- AlbumGrid.razor.css / AlbumDetail.razor.css: CSS aspect-ratio fallback
- masonryObserver.js: MutationObserver-based setup/teardown replaces
  OnAfterRenderAsync-based apply, eliminating layout-timing races
- AlbumGrid.razor / AlbumDetail.razor: one-time setup with _masonrySetup guard
2026-07-21 18:13:24 +02:00
REDCODE 6f60ade6df fix: eliminate masonry layout race on infinite scroll and banner double-fetch (#146) 2026-07-21 16:55:02 +02:00
REDCODE eaa067134e fix: use status CSS variables for progress bar segment colors
ProgressBar segments now use the same --status-* CSS custom properties as the status dots, ensuring visual consistency. ProgressBar applies CSS variable colors via inline background-color style.
2026-07-21 16:39:22 +02:00
REDCODE 701c4360b4 Merge branch 'hotfix/v1.1-bugfixes'
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m42s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 3m6s
2026-07-21 15:59:23 +02:00
REDCODE e132d269eb Merge branch 'hotfix/v1.1-bugfixes' into develop 2026-07-21 15:59:14 +02:00
REDCODE 79252d363d fix: sort album assets by OriginalFilename instead of CreatedAt (#144) 2026-07-21 15:55:58 +02:00
REDCODE 8ff21fa56b fix: use pre-pagination album count (#143)
Add AlbumTotalCount [NotMapped] to Person model, set before pagination in FindVisible, read in PersonMapper instead of paginated Albums.Count. Also apply inline aspect-ratio only in masonry mode so grid mode uses CSS aspect-ratio: 1 for square crops.
2026-07-21 15:47:21 +02:00
REDCODE 2305e44e68 fix: restore masonryLayout.dispose that was lost in reset edit 2026-07-21 15:11:30 +02:00
REDCODE 439df56f2b fix: use per-card aspect-ratio from cover dimensions for stable masonry layout (#146) 2026-07-21 15:05:02 +02:00
REDCODE f70f256c07 fix: add aspect-ratio to album cards for stable masonry height (#146) 2026-07-21 14:39:27 +02:00
REDCODE 3b1f26cdf3 fix: replace overflow-y with JS masonry for stable album layout (#146) 2026-07-21 14:26:31 +02:00
REDCODE 9965a13f40 fix: order album assets by CreatedAt in album detail (#144) 2026-07-21 14:26:29 +02:00
REDCODE fbafa54322 fix: prevent masonry layout shift on infinite scroll
Added overflow-y: scroll to html element to always reserve scrollbar
space. When content grows beyond viewport, the scrollbar appearance
would reduce container width by ~17px, causing CSS column masonry to
recalculate and shift items leftward. Fixes #146.
2026-07-21 14:16:07 +02:00
REDCODE 9607746912 fix: allow manual folder scan when scheduled scan is off
QueueFileSystemCrawl() relied on the 'folders' field which is only
populated when FolderScanEnabled is true. Now falls back to fetching
active folders from the DB when the cached list is empty. Fixes #151.
2026-07-21 14:14:41 +02:00
REDCODE 785d33e289 fix: hide register form when registration is disabled
Adds GET /api/auth/register endpoint to check registration status.
Register page now pre-checks on load and shows EmptyState instead of
the form when registration is disabled. Fixes #149.
2026-07-21 14:14:18 +02:00
REDCODE a3eeb8b6d1 fix: show registration disabled message on 403
LoginService.Register() now returns HttpStatusCode instead of bool,
so the frontend can distinguish between 403 (registration disabled),
409 (duplicate), and other failures. Fixes #149.
2026-07-21 14:13:06 +02:00
REDCODE cae61116c8 fix: jobs past tab zero-based pagination
Frontend was requesting page 1 as the first page, but the backend
uses zero-based pagination (page 0 = first). The past jobs tab never
loaded any results. Fixes #145.
2026-07-21 14:12:25 +02:00
REDCODE ced0f61b87 fix: cropper handle size constant mismatch (14→18 px)
CSS defines handles as 18×18px but JS positioned them as 14px,
causing visible misalignment. Fixes #150.
2026-07-21 14:11:42 +02:00
REDCODE 9751c81574 Merge branch 'master' into develop 2026-07-17 04:47:00 +02:00
REDCODE 550f195cf2 Apply deploy config override fix
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m33s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 2m54s
2026-07-17 03:47:47 +02:00
REDCODE 06c6cf7648 fix: remove redundant AddJsonFile calls that overrode env vars
Removed the explicit AddJsonFile calls in Program.cs — WebApplication.CreateBuilder
already adds them internally, but in the wrong order (after env vars). This caused
DatabaseAddress__Host env var from docker-compose to be silently overridden by
appsettings.json defaults, resulting in connection to 127.0.0.1:3306 instead of
database:5432.

Also updated docker-compose.yml to set DatabaseAddress__Host and DatabaseAddress__Port
explicitly, and default ASPNETCORE_ENVIRONMENT to Production.
2026-07-17 03:47:11 +02:00
REDCODE a5b6642b94 ci: auto-run on tags only, manual dispatch on branches 2026-07-17 02:19:41 +02:00
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
36 changed files with 579 additions and 495 deletions
+1
View File
@@ -0,0 +1 @@
GIT_VERSION=v1.0.2-21-g9e67c59
+34 -10
View File
@@ -4,27 +4,52 @@ on:
push:
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]
service: [Lactose, MilkStream]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Set registry host
run: echo "REGISTRY_HOST=git.r3d.codes" >> $GITHUB_ENV
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_HOST }}
registry: git.r3d.codes
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
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=APP_VERSION=$VERSION" >> $GITHUB_ENV
fi
- name: Build and push ${{ matrix.service }}
uses: docker/build-push-action@v5
@@ -33,7 +58,6 @@ jobs:
file: ./${{ matrix.service }}/Dockerfile
push: true
tags: |
${{ env.REGISTRY_HOST }}/${{ gitea.repository }}/${{ matrix.service }}:${{ gitea.ref_name }}
${{ env.REGISTRY_HOST }}/${{ gitea.repository }}/${{ matrix.service }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
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 }}
-320
View File
@@ -1,320 +0,0 @@
# MilkyShots Agent Guidelines
This file captures the coding conventions, architecture rules, and standards extracted from the codebase. Follow them in every contribution to keep the project consistent.
---
## Project Overview
MilkyShots is a .NET 8 media-library application composed of four projects:
| Project | Role |
|---|---|
| **Butter** | Shared class library DTOs, enums, MIME-type tables, settings definitions |
| **Lactose** | ASP.NET Core Web API backend controllers, EF Core models, repositories, services, background jobs |
| **MilkStream** | Blazor WebAssembly host serves static WASM files and provides dynamic configuration |
| **MilkStream.Client** | Blazor WASM client Razor components, frontend services, SCSS styles (runs in browser) |
The backend stores data in **PostgreSQL** (with the `pgvecto-rs` extension) accessed via **Entity Framework Core**.
The WASM client runs entirely in the browser and communicates with the Lactose backend over HTTP/JSON using named `HttpClient` instances.
---
## Technology Stack
- **.NET 8 / C# 12** — target framework and language version for all projects.
- **ASP.NET Core** (Lactose) and **Blazor WebAssembly** (MilkStream.Client hosted by MilkStream).
- **Entity Framework Core** with `Npgsql.EntityFrameworkCore.PostgreSQL`.
- **JWT Bearer** authentication + refresh-token rotation.
- **Bootstrap 5** and **SCSS** (compiled by `AspNetCore.SassCompiler` in debug) for styling.
- **Docker / docker-compose** for containerised deployment.
---
## Solution Structure
```
MilkyShots/
├── Butter/ # Shared library
│ ├── Dtos/ # DTOs grouped by entity (Album/, Asset/, …)
│ ├── Settings/ # Settings enum + extensions
│ └── Types/ # Shared enums (EAccessLevel, EAssetType)
├── Lactose/ # Web API
│ ├── Authorization/ # Claims transformation + authorization handlers
│ ├── Configuration/ # Options classes (SignKeyConfiguration, SignKeyProvider)
│ ├── Context/ # EF Core DbContext (LactoseDbContext)
│ ├── Controllers/ # API controllers
│ ├── Jobs/ # Background job infrastructure + concrete jobs
│ ├── Mapper/ # Model → DTO mapping (static extension methods)
│ ├── Migrations/ # EF Core migrations (generated do NOT hand-edit)
│ ├── Models/ # EF Core entity classes
│ ├── Repositories/ # Interfaces + implementations for data access
│ ├── Services/ # Application services (auth, DB initialiser, scheduler)
│ └── Utils/ # Small helpers / extension methods
├── MilkStream/ # Blazor WASM static host
│ ├── Program.cs # Minimal API serves WASM files + dynamic /appsettings.json endpoint
│ └── appsettings.json # Server-side config (LactoseBaseUrl for dynamic injection)
└── MilkStream.Client/ # Blazor WASM client (runs in browser)
├── Components/ # Razor components (Pages/, Layout/, SettingBoxes/, …)
├── Services/ # Frontend services (LoginService, MediaService, …)
├── Styles/ # SCSS source files
└── wwwroot/ # Static assets bundled into WASM
```
---
## Naming Conventions
### C# identifiers
| Category | Pattern | Example |
|---|---|---|
| Enums | `E` prefix | `EAccessLevel`, `EAssetType`, `EJobStatus` |
| Interfaces | `I` prefix | `IAssetRepository`, `IAlbumRepository` |
| DTOs | `…Dto` suffix (PascalCase) | `AssetDto`, `AlbumCreateDto` |
| DTO sets: create / update / preview / full | `…CreateDto`, `…UpdateDto`, `…PreviewDto`, `…FullDto` | `AlbumCreateDto` |
| Repository interfaces | `I…Repository` | `IAssetRepository` |
| Repository implementations | `…Repository` | `AssetRepository` |
| Mapper classes | `…Mapper` | `AssetsMapper`, `UsersMapper` |
| Controller classes | `…Controller` | `AssetController` |
| Background jobs | extend `Job` | `FileSystemCrawlJob` |
| Services | `…Service` | `LactoseAuthService`, `LoginService` |
> **Note:** The suffix `DTO` (all-caps) appears in one legacy file (`PagedParametersDTO.cs`).
> New files must use `Dto` (mixed-case) to match the rest of the codebase.
### Files and directories
- One public type per file; file name equals the type name.
- Folders mirror the namespace suffix (e.g. `Lactose.Repositories``Lactose/Repositories/`).
---
## C# Coding Style
- Enable **nullable reference types** (`<Nullable>enable</Nullable>`) and **implicit usings** in all projects.
- Prefer **primary constructors** (C# 12) for constructor injection:
```csharp
public class AssetController(
ILogger<AssetController> logger,
LactoseAuthService authService,
IAssetRepository assetRepository
) : ControllerBase { … }
```
- Use **`var`** for local variables when the type is obvious from the right-hand side.
- Use **object initialisers** when creating entity or DTO instances.
- Use the **`required`** keyword on properties that must be set during object construction.
- Default string properties to `string.Empty`, not `null`.
- Default collection navigation properties to `null` (lazy) — the EF Core model builder wires up relationships.
- Mark navigation property regions with `#region Navigation Properties … #endregion`.
### Documentation comments
- Every **model class** and every property on it must have a `/// <summary>` XML comment.
- Every **interface method** must have `<summary>`, `<param>`, and `<returns>` XML comments.
- **Mapper classes** must have a `<summary>` on the class and on each method.
- Controllers may omit XML comments on action methods (use inline comments instead).
### Logging
- Inject `ILogger<T>` into every controller and service.
- Use structured logging with message templates (avoid string concatenation in log calls where possible).
- Use verbatim interpolated string literals (`$"""…"""`) for multi-line log entries.
- Log-level conventions:
| Level | When to use |
|---|---|
| `LogTrace` | Detailed per-request data (IDs, field values) |
| `LogDebug` | Medium-detail operational info |
| `LogInformation` | Key lifecycle events (job started, user registered, …) |
| `LogWarning` | Non-fatal problems (missing resource, permission violation) |
| `LogError` | Exceptions and failures |
---
## Entity / Model Rules
- Primary keys are **`Guid`** annotated with `[Key]`.
- String columns must specify `[Column(TypeName = "VARCHAR(n)")]` with an appropriate max length.
- Foreign-key scalar properties use `[ForeignKey(nameof(NavigationProperty))]`.
- Timestamps follow the pattern: `CreatedAt`, `UpdatedAt`, `DeletedAt` (nullable `DateTime?` for soft delete).
- **Soft delete** — entities are never hard-deleted from the database via normal application flows; set `DeletedAt = DateTime.Now` instead.
- `[Index(nameof(Field), IsUnique = true)]` is used on fields that must be unique at the DB level.
- Model classes live in `Lactose.Models`; shared enums/types live in `Butter.Types`.
---
## Repository Pattern
- Every repository exposes its contract through an `I…Repository` interface in the same folder.
- All repository interfaces implement `IDisposable` (or `IAsyncDisposable` where async teardown is needed).
- Standard method set:
| Method | Signature |
|---|---|
| Find by PK | `T? Find(Guid id)` |
| Find multiple | `IEnumerable<T> FindBulk(IEnumerable<Guid> ids)` |
| Insert | `void Insert(T entity)` |
| Update | `void Update(T entity)` (sets `UpdatedAt = DateTime.Now`) |
| Bulk update | `void UpdateBulk(IEnumerable<T> entities)` |
| Save | `void Save()` → `context.SaveChanges()` |
| Delete | `void Delete(T entity)` or `void Delete(Guid id)` |
- **`Save()` must be called explicitly** after insert/update/delete operations; repositories do not auto-save except `FolderRepository.Create()` (which needs to fire an event after commit).
- Repositories may expose **static events** for cross-service notification (e.g. `FolderRepository.FolderAdded`, `SettingsRepository.SettingChanged`). Keep these static and `EventHandler<T>` typed.
- Register all repositories as **`Transient`** in `Program.cs`.
---
## Mapper / DTO Rules
- Mapper classes are **`public static`** and live in `Lactose.Mapper`.
- Mapping methods are **extension methods** on the model type:
```csharp
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) { … }
```
- Naming convention: `To<TargetType>()` or `To<TargetType>(extraParam)`.
- Never put business logic in mappers — only field assignments.
---
## Controller Rules
- Decorate every controller with `[ApiController]` and `[Route("api/[controller]")]`.
- Route URLs are lowercase (`builder.Services.Configure<RouteOptions>(o => o.LowercaseUrls = true)`).
- HTTP verb → CRUD mapping:
| Verb | Operation |
|---|---|
| `[HttpGet]` / `[HttpGet("{id}")]` | Read single / search/list |
| `[HttpPut]` | Create a new resource |
| `[HttpPost("{id}")]` | Update a specific resource |
| `[HttpPost]` | Bulk update |
| `[HttpDelete("{id}")]` | Delete a specific resource |
| `[HttpDelete]` | Bulk delete |
- Return `ActionResult<T>` for endpoints that return a body, and `IStatusCodeActionResult` or `ActionResult` for status-only responses.
- Standard error returns: `NotFound()`, `BadRequest()`, `Unauthorized()`, `Forbid()`, `Conflict(…)`.
- Always validate `null` after a repository lookup and return `NotFound()` before proceeding.
- Access-level checks must be performed inside the action method as well as via `[Authorize]` attributes (defence-in-depth).
---
## Authentication & Authorisation
- Auth uses **JWT Bearer** tokens (`JwtBearerDefaults.AuthenticationScheme`).
- Access is controlled by three roles (in ascending privilege order):
| Role | Description |
|---|---|
| `User` | Read-only access to publicly shared / explicitly shared content |
| `Curator` | Can create/update/delete their own content |
| `Admin` | Unrestricted access |
- `[Authorize]` with no roles = any authenticated user.
- `[Authorize(Roles = "Admin")]` or `[Authorize(Roles = "Admin, Curator")]` for restricted endpoints.
Note the space after the comma in the role list — this is the project's established style.
- Use `LactoseAuthService.GetUserData(User)` inside action methods to retrieve the typed `LactoseAuthenticatedUser` from claims.
- Refresh tokens are stored (hashed by ASP.NET Identity) in the `User` table.
`RefreshTokenTransformation` (a `IClaimsTransformation`) validates the refresh token on every request and adds a `"LoginValid"` claim.
- Access tokens expire after **10 minutes**; refresh tokens expire after **60 minutes**.
---
## Background Jobs System
- All background jobs **extend `Job`** (abstract base in `Lactose.Jobs`).
- Override `TaskJob(CancellationToken token)` to implement job logic.
- Check `token.IsCancellationRequested` inside loops and call `Status.Cancel()` + `return` when set.
- Use `Status.UpdateProgress(float, string)` to report progress (value between 0 and 1).
- Use `Status.Complete(string)` / `Status.Fail(string)` for terminal states.
- New job types must be instantiatable via `ActivatorUtilities.CreateInstance<T>(serviceProvider, extraArgs)`.
- Enqueue jobs with `JobManager.CreateJob<T>(args)` then `JobManager.EnqueueJob(job)`.
- Maximum concurrent jobs defaults to **8** (`JobManager.MaxConcurrentJobs`).
- Timer-based scheduling is handled by `JobScheduler` which reacts to `SettingsRepository.SettingChanged`.
---
## Settings System
- Runtime settings are persisted in the `Settings` database table.
- Canonical names are defined in `Butter.Settings.Settings` enum with `AsString()` extension.
- Default values and metadata are loaded from **`Lactose/DefaultSettings.json`** on startup by `DbInitializer`.
- When adding a new setting:
1. Add an entry to `Settings` enum and `AsString()`.
2. Add the default entry to `DefaultSettings.json`.
3. React to changes via `SettingsRepository.SettingChanged` event.
---
## Frontend (MilkStream.Client / Blazor WASM)
- Use **Blazor WebAssembly** the client runs entirely in the browser.
- Pages live in `MilkStream.Client/Components/Pages/`, reusable components in `MilkStream.Client/Components/` (or a subfolder by concern).
- Services that call the backend extend either `ServiceBase` (no auth) or `AuthServiceBase` (requires auth).
- `AuthServiceBase` subscribes to `LoginService.AuthInfoChanged` to keep the `Authorization` header up to date.
- Use the **`"MilkStreamClient"`** named `HttpClient` (configured with `JwtTokenRefresher`) for any authenticated API call.
- `JwtTokenRefresher` automatically refreshes the access token when it is expired or expires within 1 minute.
- Use `ProtectedLocalStorage` / `ProtectedSessionStorage` (Blazor's encrypted storage) for auth-related persistence.
### Dynamic Configuration
- MilkStream's `Program.cs` registers a `GET /appsettings.json` endpoint **before** `UseStaticFiles()`, shadowing the static file.
- This endpoint returns `{ "BaseUrl": "<LactoseBaseUrl>" }` from server-side configuration, ensuring the WASM client always receives the externally-reachable Lactose URL.
- The `LactoseBaseUrl` setting is injected via environment variable in Docker deployments and configured in `appsettings.json` / `appsettings.Development.json` for local development.
- SCSS source files go in `MilkStream.Client/Styles/`; compiled output is written to `MilkStream.Client/wwwroot/css/` (do not edit the compiled files by hand).
---
## Docker / Deployment
- Each deployable project has its own **multi-stage `Dockerfile`** (build → publish → base).
- `docker-compose.yml` at repo root wires up `lactose`, `milkstream`, and `database` services.
- Media is mounted as a **read-only volume** at `/diary` inside the `lactose` container.
- Data-protection keys for MilkStream are persisted via a named Docker volume.
- Environment-specific config is loaded from `appsettings.{EnvironmentName}.json`; secrets are injected via environment variables or Docker secrets — **never commit real credentials**.
- When `CorsAllowedOrigins` is set to `["*"]` in Lactose configuration, the API accepts requests from any origin (useful for browser-based WASM clients in unpredictable deployment environments). Lock this down to specific origins in production if needed.
---
## Configuration & Secrets
- `appsettings.json` contains placeholder/default values only.
- Sensitive values (DB password, JWT signing key) must be provided via environment variables or .NET User Secrets (development only, keyed by `UserSecretsId`).
- The JWT signing key (`SignKey:Key`) must be **at least 32 characters**.
- The DB port mapping in `docker-compose.yml` exposes PostgreSQL on host port `3306` (maps to container port `5432`).
---
## Git & Commit Conventions
This project uses **Conventional Commits** and generates a changelog with **git-cliff**.
| Prefix | Purpose |
|---|---|
| `feat:` | New feature |
| `fix:` | Bug fix |
| `refactor:` | Code restructuring without behaviour change |
| `perf:` | Performance improvement |
| `doc:` | Documentation changes |
| `style:` | Formatting / style only |
| `test:` | Tests |
| `chore:` / `ci:` | Build system, tooling, CI |
| `revert:` | Revert a previous commit |
- Commits with `chore(release):`, `chore(deps)`, `chore(pr)`, `chore(pull)` are excluded from the changelog automatically.
- Breaking changes must be marked with `[**breaking**]` in the commit body or footer.
---
## TODOs & Known Gaps (as of initial analysis)
The following items appear as `TODO` comments in the codebase and should be tracked:
- `AssetController.Create` — endpoint marked `[NonAction]`; purpose unclear, may need removal or redesign.
- `AlbumController`, `FolderController`, `SettingsController`, `TagController` — logging not yet wired in.
- `AuthController.Register` — should return a full `AuthResultDto` instead of a bare Guid.
- `UserController.Get` / `GetAll` — lower access levels should receive reduced user information.
- `FileSystemCrawlJob.AssetFromPath` — folder ID is not populated when crawling.
- `TagController.Get` (ancestors traversal) — not protected against circular references.
- Self-registration flow in `AuthController` is gated by the `UserRegistrationEnabled` setting but that check is not yet implemented in the controller.
+3 -1
View File
@@ -20,6 +20,8 @@
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
<method v="2">
<option name="RunConfigurationTask" enabled="true" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
</method>
</configuration>
</component>
+3 -1
View File
@@ -29,6 +29,8 @@
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
<method v="2">
<option name="RunConfigurationTask" enabled="true" run_configuration_name="Unnamed" run_configuration_type="ShConfigurationType" />
</method>
</configuration>
</component>
+8
View File
@@ -27,6 +27,14 @@ public class AlbumPreviewDto {
/// </summary>
public Guid? CoverAssetId { get; set; }
/// <summary>
/// Gets or sets the cover image width, if a cover exists.
/// </summary>
public int? CoverWidth { get; set; }
/// <summary>
/// Gets or sets the cover image height, if a cover exists.
/// </summary>
public int? CoverHeight { get; set; }
/// <summary>
/// Gets or sets the number of assets in the album.
/// </summary>
public int AssetCount { get; set; }
+10
View File
@@ -97,6 +97,16 @@ public class AuthController(
};
}
/// <summary>
/// Checks whether user registration is currently enabled.
/// </summary>
/// <returns>200 if enabled, 403 if disabled.</returns>
[HttpGet("register")]
public ActionResult CheckRegistrationEnabled() {
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
}
/// <summary>
/// Registers a new user account.
/// </summary>
+8 -3
View File
@@ -8,15 +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
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
+2
View File
@@ -21,6 +21,8 @@ public static class AlbumMapper {
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
CoverWidth = album.CoverAsset?.ResolutionWidth,
CoverHeight = album.CoverAsset?.ResolutionHeight,
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
DeletedAt = null
+1 -1
View File
@@ -27,7 +27,7 @@ public static class PersonMapper {
ProfileCropX = person.ProfileCropX,
ProfileCropY = person.ProfileCropY,
ProfileCropZoom = person.ProfileCropZoom,
TotalAlbums = albums.Count,
TotalAlbums = person.AlbumTotalCount > 0 ? person.AlbumTotalCount : albums.Count,
TotalAssets = albums.Sum(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0),
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null,
Albums = albums.Select(a => a.ToAlbumPreviewDto(accessLevel, viewerId)).ToList(),
+7
View File
@@ -79,4 +79,11 @@ public class Person {
public List<User>? Maintainers { get; set; }
#endregion
/// <summary>
/// Gets or sets the total number of albums (before pagination).
/// Populated by <c>FindVisible</c>; ignored by EF Core.
/// </summary>
[NotMapped]
public int AlbumTotalCount { get; set; }
}
-4
View File
@@ -18,10 +18,6 @@ using Npgsql;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
//Adds configurations to the builder
builder.Configuration.AddJsonFile("appsettings.json", true, true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true);
builder.Services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
// CORS: allow the Blazor WASM frontend to call this API from the browser.
+5 -4
View File
@@ -70,6 +70,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
var pagedAlbums = context.Albums
.Include(a => a.PersonOwner)
.Include(a => a.CoverAsset)
.Where(a => albumIds.Contains(a.Id))
.ToList();
@@ -141,14 +142,14 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
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),
EAccessLevel.Admin => [.. album.Assets.OrderBy(a => a.OriginalFilename)],
EAccessLevel.Curator => album.Assets.Where(a => a.DeletedAt == null || a.UploadedBy == userId).OrderBy(a => a.OriginalFilename).ToList(),
EAccessLevel.Maintainer when userId.HasValue => [.. FilterAssetsForMaintainer(album, userId.Value).OrderBy(a => a.OriginalFilename)],
_ => 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()
)).OrderBy(a => a.OriginalFilename).ToList()
};
return album;
+1
View File
@@ -115,6 +115,7 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
}
// Apply album pagination
person.AlbumTotalCount = person.Albums.Count;
person.Albums = person.Albums
.Skip(albumPage * albumPageSize)
.Take(albumPageSize)
+12 -2
View File
@@ -158,9 +158,19 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogWarning("A file system crawl job is already in progress, cannot queue another at this moment.");
return;
}
// if the scheduled scan list isn't populated, fetch active folders directly from DB
var targetFolders = folders;
if (targetFolders == null || targetFolders.Count == 0) {
using var scope = serviceProvider.CreateScope();
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
targetFolders = folderRepo.GetAll().Where(f => f.Active).ToList();
}
// queue a crawl job for each active folder
folders?.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
logger?.LogInformation("Queued file system crawl jobs.");
targetFolders.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
if (targetFolders.Count > 0)
logger?.LogInformation("Queued {Count} file system crawl job(s).", targetFolders.Count);
else
logger?.LogWarning("No active folders to scan.");
}
/// <summary>
+7 -7
View File
@@ -106,13 +106,13 @@
[Parameter] public IReadOnlyList<JobStatusDto>? Children { get; set; }
static readonly Dictionary<EJobStatus, string> _statusColors = new() {
[EJobStatus.Queued] = "secondary",
[EJobStatus.Running] = "primary",
[EJobStatus.Waiting] = "info",
[EJobStatus.Completed] = "success",
[EJobStatus.CompletedWithErrors] = "warning",
[EJobStatus.Failed] = "danger",
[EJobStatus.Canceled] = "secondary",
[EJobStatus.Queued] = "var(--status-queued)",
[EJobStatus.Running] = "var(--status-running)",
[EJobStatus.Waiting] = "var(--status-waiting)",
[EJobStatus.Completed] = "var(--status-completed)",
[EJobStatus.CompletedWithErrors] = "var(--status-completed-with-errors)",
[EJobStatus.Failed] = "var(--status-failed)",
[EJobStatus.Canceled] = "var(--status-canceled)",
};
static readonly EJobStatus[] _segmentOrder = [
@@ -4,6 +4,7 @@
@implements IDisposable
@using Butter.Dtos.User
@using Microsoft.IdentityModel.JsonWebTokens
@using System.Reflection
<NavMenu/>
<main class="container-xxl flex-grow-1">
@@ -12,7 +13,7 @@
</div>
</main>
<footer class="container-xxl footer d-none d-sm-flex justify-content-center">
<span class="text-primary m-auto">Footer (duh!)</span>
<span class="text-primary m-auto">MilkyShots - &copy; 2024 - @DateTime.Now.Year | @AppVersion</span>
</footer>
<div id="blazor-error-ui">
@@ -23,6 +24,9 @@
@code {
CancellationTokenSource? _keepaliveCts;
string AppVersion => System.Reflection.Assembly.GetExecutingAssembly()
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()
?.InformationalVersion ?? "unknown";
protected override void OnInitialized() {
LoginService.ForceLogout += OnForceLogout;
@@ -254,6 +254,7 @@
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string viewMode = "masonry";
bool _masonrySetup;
void SetMasonry() { viewMode = "masonry"; StateHasChanged(); }
void SetGrid() { viewMode = "grid"; StateHasChanged(); }
@@ -297,8 +298,12 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (album != null && album.Images.Count > 0 && viewMode == "masonry") {
await jsRuntime.InvokeVoidAsync("masonryLayout.apply", ".album-assets-grid.masonry");
if (viewMode == "masonry" && !_masonrySetup && album != null && album.Images.Count > 0) {
_masonrySetup = true;
await jsRuntime.InvokeVoidAsync("masonryLayout.setup", ".album-assets-grid.masonry");
} else if (viewMode != "masonry" && _masonrySetup) {
_masonrySetup = false;
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
}
}
@@ -503,6 +508,6 @@
public async ValueTask DisposeAsync()
{
await jsRuntime.InvokeVoidAsync("masonryLayout.dispose", ".album-assets-grid.masonry");
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
}
}
@@ -70,6 +70,10 @@
transition: filter 0.2s;
}
.album-assets-grid.masonry .masonry-tile {
aspect-ratio: 3 / 4;
}
.album-assets-grid.grid .masonry-tile {
position: relative;
}
@@ -222,7 +222,8 @@
SelectedIds="@selectedAlbumIds"
OnToggleSelection="@ToggleSelectAlbum"
LoadVersion="@albumLoadVersion"
FetchAlbums="@FetchPersonAlbums" />
FetchAlbums="@FetchPersonAlbums"
OnDataLoaded="@OnGridAlbumsLoaded" />
</div>
}
@@ -363,13 +364,16 @@
async Task LoadPerson() {
isLoading = true;
person = await personService.GetByIdAsync(Id, 0, 30);
bannerCoverUrls = person?.Albums?
isLoading = false;
}
async Task OnGridAlbumsLoaded(List<AlbumPreviewDto> albums) {
bannerCoverUrls = albums
.Where(a => a.CoverAssetId.HasValue)
.OrderBy(_ => Random.Shared.Next())
.Take(3)
.Select(a => ThumbUrl(a.CoverAssetId!.Value))
.ToList() ?? [];
isLoading = false;
.ToList();
}
async Task<List<AlbumPreviewDto>?> FetchPersonAlbums(int page, int pageSize, string? search, string? sortBy, bool sortAsc) {
@@ -131,10 +131,10 @@
<JobTree Jobs="_filteredSorted" FetchChildren="FetchChildren" EmptyText="" PollInterval="pollInterval" />
@if (pastJobTotal > pastJobPageSize) {
<div class="d-flex align-items-center justify-content-center gap-2 py-1 border-top">
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage <= 1)" @onclick="() => LoadPastJobs(pastJobPage - 1)">
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage <= 0)" @onclick="() => LoadPastJobs(pastJobPage - 1)">
<i class="bi bi-chevron-left"></i> Prev
</button>
<span class="small text-muted">@pastJobPage / @pastJobTotalPages</span>
<span class="small text-muted">@(pastJobPage + 1) / @pastJobTotalPages</span>
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage >= pastJobTotalPages)" @onclick="() => LoadPastJobs(pastJobPage + 1)">
Next <i class="bi bi-chevron-right"></i>
</button>
@@ -155,7 +155,7 @@
List<JobStatusDto> activeRoots = [];
List<JobStatusDto> pastJobRoots = [];
int pastJobPage = 1;
int pastJobPage = 0;
int pastJobPageSize = 15;
int pastJobTotal = 0;
int pastJobTotalPages => Math.Max(1, (int)Math.Ceiling((double)pastJobTotal / pastJobPageSize));
@@ -213,11 +213,11 @@
return;
}
LoginService.LoggedUserChanged += async (_, _) => {
await RefreshActiveRoots();
await LoadPastJobs(1);
await RefreshActiveRoots();
await LoadPastJobs(0);
};
await RefreshActiveRoots();
await LoadPastJobs(1);
await LoadPastJobs(0);
StartPolling();
}
@@ -232,7 +232,7 @@
StartPolling();
} else {
StopPolling();
await LoadPastJobs(1);
await LoadPastJobs(0);
}
}
@@ -310,7 +310,7 @@
await JobsService.StartJob(jobType);
errorMessage = null;
await RefreshActiveRoots();
if (pastJobPage != 1) pastJobPage = 1;
if (pastJobPage != 0) pastJobPage = 0;
await LoadPastJobs(pastJobPage);
} catch (HttpRequestException ex) when ((int?)ex.StatusCode == 501) {
errorMessage = $"{jobType} is not yet implemented on the server.";
@@ -1,3 +1,4 @@
@using System.Net
@page "/Register"
@inject LoginService loginService
@@ -5,30 +6,39 @@
<PageTitle>Register</PageTitle>
<div class="d-flex flex-column mx-sm-auto mx-3">
@if (!string.IsNullOrEmpty(_error)) {
<div class="m-2 p-1 border-danger border-2 rounded-3 bg-danger-subtle">
<div class="text-danger-emphasis m-2">@_error</div>
</div>
}
<form class="d-flex flex-column align-items-center">
<input type="text" class="form-control my-1" placeholder="Username" required="required" id="username"
@bind="_username" @bind:event="oninput"/>
<input type="email" class="form-control my-1" placeholder="Email" required="required" id="email"
@bind="_email" @bind:event="oninput"/>
@if (_checkingStatus) {
<LoadSpinner/>
} else if (_registrationDisabled) {
<EmptyState Variant="warning" Title="Registration Closed">
<p>New user registration is currently disabled on this server.</p>
<a href="/login" class="btn btn-primary">Go to Login</a>
</EmptyState>
} else {
<div class="d-flex flex-column mx-sm-auto mx-3">
@if (!string.IsNullOrEmpty(_error)) {
<div class="m-2 p-1 border-danger border-2 rounded-3 bg-danger-subtle">
<div class="text-danger-emphasis m-2">@_error</div>
</div>
}
<form class="d-flex flex-column align-items-center">
<input type="text" class="form-control my-1" placeholder="Username" required="required" id="username"
@bind="_username" @bind:event="oninput"/>
<input type="email" class="form-control my-1" placeholder="Email" required="required" id="email"
@bind="_email" @bind:event="oninput"/>
<PasswordField @ref="_passwordField"
@bind-Password="_password"
@bind-ConfirmPassword="_confirmPassword" />
<PasswordField @ref="_passwordField"
@bind-Password="_password"
@bind-ConfirmPassword="_confirmPassword" />
<div class="d-flex flex-row justify-content-around w-100 mt-2">
<button class="btn btn-primary m-2" type="button" id="register-button"
disabled="@(!CanSubmit)" @onclick="Register_OnClick">Register</button>
<button class="btn btn-primary m-2" type="button" id="login-button"
@onclick="Login_OnClick">Login</button>
</div>
</form>
</div>
<div class="d-flex flex-row justify-content-around w-100 mt-2">
<button class="btn btn-primary m-2" type="button" id="register-button"
disabled="@(!CanSubmit)" @onclick="Register_OnClick">Register</button>
<button class="btn btn-primary m-2" type="button" id="login-button"
@onclick="Login_OnClick">Login</button>
</div>
</form>
</div>
}
@code {
PasswordField? _passwordField;
@@ -37,18 +47,29 @@
string _password = string.Empty;
string _confirmPassword = string.Empty;
string _error = string.Empty;
bool _checkingStatus = true;
bool _registrationDisabled;
bool CanSubmit => _passwordField?.IsValid == true
&& !string.IsNullOrWhiteSpace(_username)
&& !string.IsNullOrWhiteSpace(_email);
protected override async Task OnInitializedAsync() {
_registrationDisabled = !await loginService.IsRegistrationEnabledAsync();
_checkingStatus = false;
}
void Login_OnClick() => navigation.NavigateTo("login");
async Task Register_OnClick() {
_error = string.Empty;
var ok = await loginService.Register(_username, _email, _password);
if (ok) {
var status = await loginService.Register(_username, _email, _password);
if (status == HttpStatusCode.OK) {
navigation.NavigateTo("login");
} else if (status == HttpStatusCode.Forbidden) {
_error = "Registration is currently disabled.";
} else if (status == HttpStatusCode.Conflict) {
_error = "Username or email already taken.";
} else {
_error = "Registration failed. Please try again.";
}
@@ -4,7 +4,7 @@
<div class="progress-stacked" style="height: @Height">
@foreach (var seg in Segments) {
<div class="progress" role="progressbar" style="width: @((seg.Width * 100).ToString(CultureInfo.InvariantCulture))%">
<div class="progress-bar @(SegmentClass(seg.Color))"></div>
<div class="progress-bar @(SegmentClass(seg.Color))" style="@(SegmentStyle(seg.Color))"></div>
</div>
}
</div>
@@ -32,7 +32,9 @@
string BgClass => ToCssClass(Color ?? "primary");
string SegmentClass(string color) => ToCssClass(color);
string SegmentClass(string color) => color.StartsWith("var(--") ? "" : ToCssClass(color);
string SegmentStyle(string color) => color.StartsWith("var(--") ? $"background-color: {color}" : "";
static string ToCssClass(string name) => name switch {
"primary" => "text-bg-primary",
@@ -5,7 +5,7 @@
@inject IOptions<ServiceOptions> serviceOptions
@inject LoginService loginService
<div class="@GetCardClass()" @key="Album.Id">
<div class="@GetCardClass()" @key="Album.Id" style="@GetCardStyle()">
<a href="/albums/@Album.Id" class="album-card-overlay" aria-label="View album @Album.Name"
@onclick="HandleOverlayClick" @onclick:preventDefault="SelectionMode"></a>
@if (Album.CoverAssetId.HasValue) {
@@ -51,6 +51,9 @@
[Parameter]
public EventCallback<Guid> OnToggleSelection { get; set; }
[Parameter]
public string ViewMode { get; set; } = "masonry";
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string token => loginService.AuthInfo?.Token ?? string.Empty;
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
@@ -71,6 +74,15 @@
return string.Join(" ", classes);
}
string GetCardStyle() {
if (ViewMode == "masonry") {
var w = Album.CoverWidth ?? 3;
var h = Album.CoverHeight ?? 4;
return $"aspect-ratio:{w}/{h}";
}
return "";
}
async Task HandleOverlayClick() {
if (SelectionMode)
await OnToggleSelection.InvokeAsync(Album.Id);
@@ -7,7 +7,7 @@
@if (isLoading) {
<LoadSpinner/>
} else if (albums.Count == 0) {
} else if (buffer.Count == 0 && !hasMore) {
<EmptyState Variant="warning" Title="No albums available">
@if (loginService.IsLoggedIn) {
<p>There are no albums yet.</p>
@@ -16,20 +16,24 @@
}
</EmptyState>
} else {
@if (isLoadingUp) {
<LoadSpinner/>
}
<div class="@("album-grid " + ViewMode)">
@foreach (var album in albums) {
<AlbumCard Album="album"
@foreach (var album in buffer.FlatList) {
<AlbumCard @key="album.Id"
Album="album"
SelectionMode="SelectionMode"
Selected="SelectedIds.Contains(album.Id)"
OnToggleSelection="ToggleAlbumSelection"/>
OnToggleSelection="ToggleAlbumSelection"
ViewMode="@ViewMode"/>
}
</div>
@if (isLoadingMore) {
<LoadSpinner/>
}
<div @ref="sentinelRef" class="masonry-sentinel"></div>
}
@code {
@@ -42,19 +46,22 @@
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
[Parameter] public int LoadVersion { get; set; }
[Parameter] public Func<int, int, string?, string?, bool, Task<List<AlbumPreviewDto>?>>? FetchAlbums { get; set; }
[Parameter] public EventCallback<List<AlbumPreviewDto>> OnDataLoaded { get; set; }
List<AlbumPreviewDto> albums = [];
int currentPage = 0;
PageBuffer<AlbumPreviewDto> buffer = new(a => a.Id) { MaxPages = 10 };
bool hasMore = true;
bool hasMoreUp => buffer.MinPage > 0;
bool isLoading = true;
bool isLoadingMore;
ElementReference sentinelRef;
bool isLoadingUp;
DotNetObjectReference<AlbumGrid>? dotNetRef;
int? _prevLoadVersion;
string? _prevSearch;
string? _prevSortBy;
bool _prevSortAsc;
string? _prevViewMode;
bool _masonrySetup;
protected override async Task OnParametersSetAsync() {
bool filterChanged = _prevLoadVersion != LoadVersion
@@ -62,6 +69,14 @@
|| _prevSortBy != SortBy
|| _prevSortAsc != SortAsc;
if (_prevViewMode == "masonry" && ViewMode != "masonry") {
_masonrySetup = false;
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-grid.masonry");
} else if (_prevViewMode != "masonry" && ViewMode == "masonry") {
_masonrySetup = false;
}
_prevViewMode = ViewMode;
_prevLoadVersion = LoadVersion;
_prevSearch = SearchQuery;
_prevSortBy = SortBy;
@@ -72,18 +87,22 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (!isLoading && (albums.Count > 0 || hasMore) && dotNetRef == null) {
if (!_masonrySetup && ViewMode == "masonry" && buffer.Count > 0) {
_masonrySetup = true;
await jsRuntime.InvokeVoidAsync("masonryLayout.setup", ".album-grid.masonry");
}
if (!isLoading && (buffer.Count > 0 || hasMore) && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"albumObserver.observeBottom", sentinelRef, dotNetRef
"albumObserver.setup", dotNetRef
);
}
}
async Task Reload() {
await DisposeObserver();
albums.Clear();
currentPage = 0;
buffer.Clear();
hasMore = true;
isLoading = true;
StateHasChanged();
@@ -92,11 +111,13 @@
? await FetchAlbums(0, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(0, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
albums = items;
currentPage = 1;
buffer.AddPage(0, items);
}
hasMore = items?.Count == 30;
isLoading = false;
if (buffer.Count > 0)
await OnDataLoaded.InvokeAsync(buffer.FlatList);
}
[JSInvokable]
@@ -105,18 +126,41 @@
isLoadingMore = true;
StateHasChanged();
var nextPage = buffer.MaxPage + 1;
var items = FetchAlbums != null
? await FetchAlbums(currentPage, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
? await FetchAlbums(nextPage, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(nextPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
albums.AddRange(items);
currentPage++;
buffer.AddPage(nextPage, items);
}
hasMore = items?.Count == 30;
isLoadingMore = false;
StateHasChanged();
}
[JSInvokable]
public async Task LoadPreviousAlbums() {
if (isLoadingUp || !hasMoreUp) return;
isLoadingUp = true;
StateHasChanged();
var prevPage = buffer.MinPage - 1;
if (prevPage < 0) {
isLoadingUp = false;
return;
}
var items = FetchAlbums != null
? await FetchAlbums(prevPage, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(prevPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
buffer.PrependPage(prevPage, items);
}
isLoadingUp = false;
StateHasChanged();
}
async Task ToggleAlbumSelection(Guid id) => await OnToggleSelection.InvokeAsync(id);
async Task DisposeObserver() {
@@ -127,15 +171,18 @@
}
}
public async ValueTask DisposeAsync() => await DisposeObserver();
public async ValueTask DisposeAsync() {
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-grid.masonry");
await DisposeObserver();
}
/// <summary>
/// Returns the IDs of all currently loaded albums.
/// </summary>
public List<Guid> GetCurrentAlbumIds() => albums.Select(a => a.Id).ToList();
public List<Guid> GetCurrentAlbumIds() => buffer.FlatList.Select(a => a.Id).ToList();
/// <summary>
/// Returns the previews of all currently loaded albums.
/// </summary>
public List<AlbumPreviewDto> GetCurrentAlbumPreviews() => [.. albums];
public List<AlbumPreviewDto> GetCurrentAlbumPreviews() => [.. buffer.FlatList];
}
@@ -3,6 +3,7 @@
column-count: 4;
column-gap: 8px;
padding: 0 8px;
overflow-anchor: none;
}
/* ── Grid ── */
@@ -11,12 +12,14 @@
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
padding: 0 8px;
overflow-anchor: none;
}
/* ── Card in masonry ── */
.album-grid.masonry ::deep .album-card {
break-inside: avoid;
margin-bottom: 8px;
aspect-ratio: 3 / 4;
}
.album-grid.grid ::deep .album-card {
@@ -30,6 +33,7 @@
.masonry-sentinel {
height: 1px;
overflow-anchor: none;
}
@media (max-width: 576px) {
@@ -11,7 +11,7 @@
@if (isLoading) {
<LoadSpinner/>
} else if (peopleDict.Count == 0 && !hasMore) {
} else if (buffer.Count == 0 && !hasMore) {
<EmptyState Variant="warning" Title="No media available">
@if (loginService.IsLoggedIn) {
<p>There are no cosplayers yet.</p>
@@ -20,8 +20,12 @@
}
</EmptyState>
} else {
@if (isLoadingUp) {
<LoadSpinner/>
}
<div class="cosplayers-grid">
@foreach (var person in peopleDict.Values) {
@foreach (var person in buffer.FlatList) {
<a href="/cosplayer/@person.Id" class="@GetCardClass(person)" @key="person.Id"
@onclick="() => HandleCardClick(person.Id)"
@onclick:preventDefault="SelectionMode">
@@ -65,8 +69,6 @@
@if (isLoadingMore) {
<LoadSpinner/>
}
<div @ref="sentinelRef" class="masonry-sentinel"></div>
}
@code {
@@ -78,12 +80,12 @@
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
[Parameter] public int LoadVersion { get; set; }
Dictionary<Guid, PersonPreviewDto> peopleDict = [];
int currentPage = 0;
PageBuffer<PersonPreviewDto> buffer = new(p => p.Id) { MaxPages = 10 };
bool hasMore = true;
bool hasMoreUp => buffer.MinPage > 0;
bool isLoading = true;
bool isLoadingMore;
ElementReference sentinelRef;
bool isLoadingUp;
DotNetObjectReference<CosplayerGrid>? dotNetRef;
int? _prevLoadVersion;
@@ -115,27 +117,24 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (!isLoading && (peopleDict.Count > 0 || hasMore) && dotNetRef == null) {
if (!isLoading && (buffer.Count > 0 || hasMore) && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"cosplayerObserver.observeBottom", sentinelRef, dotNetRef
"cosplayerObserver.setup", dotNetRef
);
}
}
async Task Reload() {
await DisposeObserver();
peopleDict.Clear();
currentPage = 0;
buffer.Clear();
hasMore = true;
isLoading = true;
StateHasChanged();
var items = await personService.GetAllAsync(0, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
foreach (var p in items)
peopleDict[p.Id] = p;
currentPage = 1;
buffer.AddPage(0, items);
}
hasMore = items?.Count == 30;
isLoading = false;
@@ -147,17 +146,37 @@
isLoadingMore = true;
StateHasChanged();
var items = await personService.GetAllAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
var nextPage = buffer.MaxPage + 1;
var items = await personService.GetAllAsync(nextPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
foreach (var p in items)
peopleDict[p.Id] = p;
currentPage++;
buffer.AddPage(nextPage, items);
}
hasMore = items?.Count == 30;
isLoadingMore = false;
StateHasChanged();
}
[JSInvokable]
public async Task LoadPreviousCosplayers() {
if (isLoadingUp || !hasMoreUp) return;
isLoadingUp = true;
StateHasChanged();
var prevPage = buffer.MinPage - 1;
if (prevPage < 0) {
isLoadingUp = false;
return;
}
var items = await personService.GetAllAsync(prevPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
buffer.PrependPage(prevPage, items);
}
isLoadingUp = false;
StateHasChanged();
}
void HandleCardClick(Guid id) {
if (SelectionMode) {
OnToggleSelection.InvokeAsync(id);
@@ -199,10 +218,10 @@
/// <summary>
/// Returns the IDs of all currently loaded cosplayers.
/// </summary>
public List<Guid> GetCurrentPersonIds() => [.. peopleDict.Keys];
public List<Guid> GetCurrentPersonIds() => buffer.FlatList.Select(p => p.Id).ToList();
/// <summary>
/// Returns the previews of all currently loaded cosplayers.
/// </summary>
public List<PersonPreviewDto> GetCurrentPersonPreviews() => [.. peopleDict.Values];
public List<PersonPreviewDto> GetCurrentPersonPreviews() => [.. buffer.FlatList];
}
@@ -3,6 +3,7 @@
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
padding: 0 8px;
overflow-anchor: none;
}
.cosplayer-card {
@@ -4,8 +4,19 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<Target Name="SetGitVersion" BeforeTargets="BeforeCompile" Condition="'$(InformationalVersion)' == ''">
<Exec Command="git describe --tags --always --dirty 2&gt;nul || echo unknown"
ConsoleToMSBuild="true" StandardOutputImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="GitVersion" />
</Exec>
<PropertyGroup>
<InformationalVersion>$(GitVersion.Trim())</InformationalVersion>
</PropertyGroup>
</Target>
<ItemGroup>
<PackageReference Include="AspNetCore.SassCompiler" Version="1.101.0" />
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
+90
View File
@@ -0,0 +1,90 @@
namespace MilkStream.Client;
/// <summary>
/// Manages a sliding-window buffer of paginated data.
/// Pages are added to the front or back, and the buffer is capped at <see cref="MaxPages"/>.
/// When the cap is exceeded, the oldest page is dropped from the opposite end.
/// </summary>
public class PageBuffer<T> where T : class {
readonly List<List<T>> _pages = [];
readonly Func<T, Guid> _keySelector;
/// <summary>
/// Initializes a new instance of <see cref="PageBuffer{T}"/> with the given key selector.
/// </summary>
/// <param name="keySelector">A function that extracts the deduplication key from each item.</param>
public PageBuffer(Func<T, Guid> keySelector) {
_keySelector = keySelector;
}
/// <summary>
/// Gets the page index of the first page in the buffer.
/// </summary>
public int MinPage { get; private set; }
/// <summary>
/// Gets the page index of the last page in the buffer.
/// </summary>
public int MaxPage { get; private set; }
/// <summary>
/// Gets or sets the maximum number of pages to keep in the buffer.
/// Defaults to 10.
/// </summary>
public int MaxPages { get; init; } = 10;
/// <summary>
/// Gets the flattened, deduplicated list of all items currently in the buffer.
/// </summary>
public List<T> FlatList { get; private set; } = [];
/// <summary>
/// Gets the number of items in the flat list.
/// </summary>
public int Count => FlatList.Count;
/// <summary>
/// Adds a page to the back of the buffer. Drops the front page if over cap.
/// </summary>
public void AddPage(int pageIndex, List<T> items) {
_pages.Add(items);
MaxPage = pageIndex;
if (_pages.Count > MaxPages) {
_pages.RemoveAt(0);
MinPage++;
}
Rebuild();
}
/// <summary>
/// Adds a page to the front of the buffer. Drops the back page if over cap.
/// </summary>
public void PrependPage(int pageIndex, List<T> items) {
_pages.Insert(0, items);
MinPage = pageIndex;
if (_pages.Count > MaxPages) {
_pages.RemoveAt(_pages.Count - 1);
MaxPage--;
}
Rebuild();
}
/// <summary>
/// Clears all pages from the buffer.
/// </summary>
public void Clear() {
_pages.Clear();
MinPage = 0;
MaxPage = 0;
FlatList = [];
}
void Rebuild() {
var seen = new Dictionary<Guid, T>();
foreach (var page in _pages)
foreach (var item in page)
if (!seen.ContainsKey(_keySelector(item)))
seen[_keySelector(item)] = item;
FlatList = [.. seen.Values];
}
}
+17 -3
View File
@@ -1,3 +1,4 @@
using System.Net;
using Blazored.LocalStorage;
using Butter.Dtos;
using Butter.Dtos.User;
@@ -265,8 +266,8 @@ public sealed class LoginService : ServiceBase {
/// <param name="username">The desired username.</param>
/// <param name="email">The email address.</param>
/// <param name="password">The password.</param>
/// <returns>True if registration succeeded.</returns>
public async Task<bool> Register(string username, string email, string password) {
/// <returns>HttpStatusCode indicating the result (200 = success, 403 = disabled, 409 = conflict).</returns>
public async Task<HttpStatusCode> Register(string username, string email, string password) {
logger.LogInformation("Attempting to register user with username: {Username}", username);
var registerDto = new UserRegisterDto() {
@@ -277,7 +278,7 @@ public sealed class LoginService : ServiceBase {
var response = await Client.PostAsJsonAsync("api/auth/register", registerDto);
logger.LogInformation("Result: {ResponseStatusCode}", response.StatusCode);
return response.IsSuccessStatusCode;
return response.StatusCode;
}
/// <summary>
@@ -321,5 +322,18 @@ public sealed class LoginService : ServiceBase {
}
}
/// <summary>
/// Checks whether user registration is currently enabled on the server.
/// </summary>
/// <returns>True if registration is allowed.</returns>
public async Task<bool> IsRegistrationEnabledAsync() {
try {
var response = await Client.GetAsync("api/auth/register");
return response.StatusCode == HttpStatusCode.OK;
} catch {
return false;
}
}
#endregion
}
+135 -49
View File
@@ -65,59 +65,82 @@ window.masonryObserver = {
}
};
window.albumObserver = {
bottomObserver: null,
dotNetRef: null,
var _makeObserver = function (loadMoreMethod, loadPrevMethod) {
var dotNetRef = null;
var wheelHandler = null;
var keyHandler = null;
var touchStartHandler = null;
var touchEndHandler = null;
var touchStartY = -1;
observeBottom: function (sentinelElement, dotNetRef) {
this.dotNetRef = dotNetRef;
if (this.bottomObserver) this.bottomObserver.disconnect();
this.bottomObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
dotNetRef.invokeMethodAsync('LoadMoreAlbums');
}
},
{ rootMargin: '600px' }
);
this.bottomObserver.observe(sentinelElement);
},
dispose: function () {
if (this.bottomObserver) {
this.bottomObserver.disconnect();
this.bottomObserver = null;
}
this.dotNetRef = null;
function isAtBottom() {
return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 100;
}
function isAtTop() {
return window.scrollY <= 100;
}
function checkBottom() {
if (isAtBottom() && dotNetRef) {
dotNetRef.invokeMethodAsync(loadMoreMethod);
}
}
function checkTop() {
if (isAtTop() && dotNetRef) {
dotNetRef.invokeMethodAsync(loadPrevMethod);
}
}
return {
setup: function (ref) {
dotNetRef = ref;
wheelHandler = function (e) {
if (e.deltaY > 0) checkBottom();
else if (e.deltaY < 0) checkTop();
};
window.addEventListener('wheel', wheelHandler, { passive: true });
keyHandler = function (e) {
if (e.key === 'ArrowDown' || e.key === 'PageDown') checkBottom();
else if (e.key === 'ArrowUp' || e.key === 'PageUp') checkTop();
};
window.addEventListener('keydown', keyHandler, { passive: true });
touchStartHandler = function (e) {
touchStartY = e.touches[0].clientY;
};
window.addEventListener('touchstart', touchStartHandler, { passive: true });
touchEndHandler = function (e) {
if (touchStartY < 0) return;
var dy = e.changedTouches[0].clientY - touchStartY;
touchStartY = -1;
if (dy < -30) checkBottom();
else if (dy > 30) checkTop();
};
window.addEventListener('touchend', touchEndHandler, { passive: true });
},
dispose: function () {
if (wheelHandler) window.removeEventListener('wheel', wheelHandler);
if (keyHandler) window.removeEventListener('keydown', keyHandler);
if (touchStartHandler) window.removeEventListener('touchstart', touchStartHandler);
if (touchEndHandler) window.removeEventListener('touchend', touchEndHandler);
wheelHandler = null;
keyHandler = null;
touchStartHandler = null;
touchEndHandler = null;
dotNetRef = null;
touchStartY = -1;
}
};
};
window.cosplayerObserver = {
bottomObserver: null,
dotNetRef: null,
observeBottom: function (sentinelElement, dotNetRef) {
this.dotNetRef = dotNetRef;
if (this.bottomObserver) this.bottomObserver.disconnect();
this.bottomObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
dotNetRef.invokeMethodAsync('LoadMoreCosplayers');
}
},
{ rootMargin: '600px' }
);
this.bottomObserver.observe(sentinelElement);
},
dispose: function () {
if (this.bottomObserver) {
this.bottomObserver.disconnect();
this.bottomObserver = null;
}
this.dotNetRef = null;
}
};
window.albumObserver = _makeObserver('LoadMoreAlbums', 'LoadPreviousAlbums');
window.cosplayerObserver = _makeObserver('LoadMoreCosplayers', 'LoadPreviousCosplayers');
window.pageLayout = {
columnCount: function () {
@@ -131,6 +154,7 @@ window.pageLayout = {
window.masonryLayout = {
_observers: new WeakMap(),
_mutations: new WeakMap(),
_doApply: function (container) {
const items = Array.from(container.children);
@@ -186,6 +210,49 @@ window.masonryLayout = {
this._observers.set(container, observer);
},
setup: function (containerSelector) {
const container = document.querySelector(containerSelector);
if (!container) return;
this.teardown(containerSelector);
const self = this;
const scheduleApply = function () {
if (container._masonryRaf) return;
container._masonryRaf = requestAnimationFrame(function () {
container._masonryRaf = null;
self._doApply(container);
});
};
var mo = new MutationObserver(function () { scheduleApply(); });
mo.observe(container, { childList: true });
this._mutations.set(container, mo);
var ro = new ResizeObserver(function () { scheduleApply(); });
ro.observe(container);
this._observers.set(container, ro);
scheduleApply();
},
teardown: function (containerSelector) {
const container = document.querySelector(containerSelector);
if (!container) return;
this.reset(containerSelector);
var mo = this._mutations.get(container);
if (mo) {
mo.disconnect();
this._mutations.delete(container);
}
if (container._masonryRaf) {
cancelAnimationFrame(container._masonryRaf);
container._masonryRaf = null;
}
},
dispose: function (containerSelector) {
const container = document.querySelector(containerSelector);
if (!container) return;
@@ -194,6 +261,25 @@ window.masonryLayout = {
obs.disconnect();
this._observers.delete(container);
}
},
reset: function (containerSelector) {
const container = document.querySelector(containerSelector);
if (!container) return;
const obs = this._observers.get(container);
if (obs) {
obs.disconnect();
this._observers.delete(container);
}
container.style.position = '';
container.style.height = '';
Array.from(container.children).forEach(function (item) {
item.style.position = '';
item.style.width = '';
item.style.left = '';
item.style.top = '';
item.style.margin = '';
});
}
};
@@ -206,7 +206,7 @@ window.profileCropper = {
s.square.style.height = sidePx + 'px';
// Handles at square corners
var hs = 14;
var hs = 18;
var corners = [
{ left: -hs / 2, top: -hs / 2 },
{ left: sidePx - hs / 2, top: -hs / 2 },
+7 -3
View File
@@ -5,17 +5,21 @@ EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
ARG APP_VERSION
WORKDIR /src
COPY ["MilkStream/MilkStream.csproj", "MilkStream/"]
COPY ["MilkStream.Client/MilkStream.Client.csproj", "MilkStream.Client/"]
COPY ["Butter/Butter.csproj", "Butter/"]
RUN dotnet restore "MilkStream/MilkStream.csproj"
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet restore "MilkStream/MilkStream.csproj"
COPY . .
WORKDIR "/src/MilkStream"
RUN dotnet build "./MilkStream.csproj" -c $BUILD_CONFIGURATION -o /app/build
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet build "./MilkStream.csproj" -c $BUILD_CONFIGURATION -o /app/build /p:InformationalVersion=$APP_VERSION
FROM build AS publish
RUN dotnet publish "./MilkStream.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish "./MilkStream.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false /p:InformationalVersion=$APP_VERSION
FROM base AS final
WORKDIR /app
+5 -1
View File
@@ -7,12 +7,14 @@
context: .
dockerfile: Lactose/Dockerfile
environment:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT}
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production}
# CorsAllowedOrigins must be the URL the *browser* uses to reach MilkStream.
# Use semicolons to specify multiple origins (e.g. http://host1;http://host2).
# Override this with your server's hostname/IP if not running on localhost.
#- CorsAllowedOrigins=http://localhost:8080
- CorsAllowedOrigins=*
- DatabaseAddress__Host=database
- DatabaseAddress__Port=5432
ports:
- "5162:8080"
depends_on:
@@ -53,6 +55,8 @@
build:
context: .
dockerfile: MilkStream/Dockerfile
args:
APP_VERSION: "${GIT_VERSION:-unknown}"
environment:
# LactoseBaseUrl must be the address the *browser* can reach Lactose at.
# Override this with your server's hostname/IP if not running on localhost.
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
# generate-version.sh
echo "GIT_VERSION=$(git describe --tags --always)" > .env