6 Commits
Author SHA1 Message Date
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
7 changed files with 97 additions and 41 deletions
+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>
+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>
@@ -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.";
}
+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
}
+4 -3
View File
@@ -329,12 +329,13 @@ $btn-theme-colors: (
}
// ── Inter font overrides ──
html, body {
html {
overflow-y: scroll;
font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
min-height: 100dvh;
}
body {
font-family: inherit;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: radial-gradient(ellipse at 50% 0%,
@@ -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 },