Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee07306a4d | ||
|
|
294f7a6cae | ||
|
|
595399385b | ||
|
|
77ee80cd63 | ||
|
|
9e67c597ef | ||
|
|
05ff6e8f2d | ||
|
|
07e43b5023 | ||
|
|
00e0dd42d1 | ||
|
|
6f60ade6df | ||
|
|
eaa067134e | ||
|
|
701c4360b4 | ||
|
|
e132d269eb | ||
|
|
79252d363d | ||
|
|
8ff21fa56b | ||
|
|
2305e44e68 | ||
|
|
439df56f2b | ||
|
|
f70f256c07 | ||
|
|
3b1f26cdf3 | ||
|
|
9965a13f40 | ||
|
|
fbafa54322 | ||
|
|
9607746912 | ||
|
|
785d33e289 | ||
|
|
a3eeb8b6d1 | ||
|
|
cae61116c8 | ||
|
|
ced0f61b87 | ||
|
|
9751c81574 | ||
|
|
550f195cf2 | ||
|
|
06c6cf7648 | ||
|
|
a5b6642b94 | ||
|
|
f0b67b0404 | ||
|
|
fb2b02e71e | ||
|
|
4658ed1a27 | ||
|
|
52c08089b2 | ||
|
|
111875670b | ||
|
|
9c50f84783 | ||
|
|
593d88d3e3 | ||
|
|
abef50b010 | ||
|
|
ce221428f5 | ||
|
|
213c643263 | ||
|
|
ecc6e02c78 | ||
|
|
b78c5772b4 | ||
|
|
6463df51d1 | ||
|
|
dd2d703fc0 |
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
@@ -20,6 +20,8 @@
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
<method v="2">
|
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -29,6 +29,8 @@
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
<method v="2">
|
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="Unnamed" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 - © 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>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" />
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# generate-version.sh
|
||||
echo "GIT_VERSION=$(git describe --tags --always)" > .env
|
||||
Reference in New Issue
Block a user