3 Commits

Author SHA1 Message Date
d6d167f40c fix: use oninput for live slider value update and widen range layout 2026-07-06 19:31:38 +02:00
e33d87bb30 feat: unify settings pages into sidebar + detail panel with auto-save
Replace two separate settings pages with a single unified page featuring:
- Sidebar navigation with 6 categories (General, Scanning, Upload, Paths, Jobs, Folders)
- Auto-save with 500ms debounce and visual feedback per setting
- Inline folder management as a dedicated tab
- Removed duplicate BeginSave event subscription bug in SettingBox components

Breaking: SettingsService.BeginSave event and OnBeginSave() removed
2026-07-06 19:30:07 +02:00
e297a81ebb feat(docker): add named volumes for thumbnails and previews 2026-07-06 19:18:34 +02:00
12 changed files with 316 additions and 247 deletions

View File

@@ -10,7 +10,7 @@
<input class="form-control form-control-sm mx-1 @Edit" type="text" @bind="Folder.BasePath"/>
<button class="btn btn-sm btn-success @Edit" @onclick="OnConfirmEdit"><i class="bi bi-check"></i></button>
<div class="ms-auto form-switch">
<input class="form-check-input m-1" type="checkbox" id="@Folder.Id" @bind="@Folder.Active"/>
<input class="form-check-input m-1" type="checkbox" id="@Folder.Id" @bind="@Folder.Active" @bind:after="() => OnToggleActive()"/>
</div>
<button class="btn btn-outline-warning btn-sm @Display" @onclick="OnEdit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-sm mx-1" @onclick="OnDelete"><i class="bi bi-folder-minus"></i></button>
@@ -25,20 +25,8 @@
[Parameter]
public FolderFullDto Folder { get; set; } = new FolderFullDto();
protected override void OnInitialized() {
FoldersService.SaveFolders += async (_, _) => {
try {
await FoldersService.UpdateFolderAsync(
Folder.Id,
new FolderUpdateDto() {
Active = Folder.Active,
BasePath = Folder.BasePath
}
);
} catch (Exception) { /* fire-and-forget: failure is silent to avoid cascading errors */ }
};
}
[Parameter]
public EventCallback<Guid> OnDeleted { get; set; }
void OnEdit() {
editing = true;
@@ -57,7 +45,16 @@
async Task OnDelete() {
await FoldersService.RemoveFolderAsync(Folder.Id);
NavigationManager.Refresh(true);
await OnDeleted.InvokeAsync(Folder.Id);
}
async Task OnToggleActive() {
await FoldersService.UpdateFolderAsync(
Folder.Id,
new FolderUpdateDto() {
Active = Folder.Active,
BasePath = Folder.BasePath
}
);
}
}

View File

@@ -56,7 +56,6 @@
<li><a class="dropdown-item" @onclick="OnProfileClick"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
@if(Admin) {
<li><a class="dropdown-item" @onclick="OnSettingsClick"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
<li><a class="dropdown-item" @onclick="OnSettings1Click"><i class="bi bi-gear-wide-connected"></i> Settings 1</a></li>
<li><a class="dropdown-item" @onclick="OnJobsClick"><i class="bi bi-kanban-fill"></i> Jobs</a></li>
}
<li><a class="dropdown-item" @onclick="OnLogout"><i class="bi bi-door-closed"></i> Logout</a></li>
@@ -98,8 +97,6 @@
void OnSettingsClick() => navigation.NavigateTo("/settings");
void OnSettings1Click() => navigation.NavigateTo("/Settings1");
void OnJobsClick() => navigation.NavigateTo("/jobs");
void OnProfileClick() => navigation.NavigateTo($"/User/{loginService.AuthInfo?.UserId}", true);

View File

@@ -4,7 +4,7 @@
@using Butter.Settings
@using MilkStream.Client.Components.SettingBoxes
@using MilkStream.Client.Services
@using System.Linq;
@using System.Linq
@inject NavigationManager NavigationManager
@inject LoginService LoginService
@@ -13,91 +13,151 @@
<PageTitle>Settings</PageTitle>
<h3>Settings</h3>
@if (settings.Any()) {
foreach (var option in settings) {
switch (option.DisplayType) {
// TODO: should implement text and password at some point
case DisplayType.Text:
<SettingString Setting="option"/>
break;
case DisplayType.Password:
<input type="password" id="@option.Name" class="form-control" @bind="@option.Value"/>
<div class="settings-layout">
<nav class="settings-sidebar">
@foreach (var group in _groups) {
<button class="settings-nav-item @(_activeGroup == group.Key ? "active" : "")"
@onclick="() => SelectGroup(group.Key)">
<i class="bi @group.Value.Icon"></i>
<span>@group.Key</span>
</button>
}
</nav>
<main class="settings-content">
@if (_isLoading) {
<div class="d-flex justify-content-center align-items-center h-100">
<LoadSpinner/>
</div>
} else if (_activeGroup == "Folders") {
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="m-0"><i class="bi bi-folder2-open"></i> Folders</h4>
<button class="btn btn-outline-primary btn-sm" @onclick="OnAddFolder">
<i class="bi bi-folder-plus"></i> Add folder
</button>
</div>
@if (_folders.Any()) {
@foreach (var folder in _folders) {
<FolderBox Folder="folder" OnDeleted="HandleFolderDeleted"/>
}
} else {
<div class="alert alert-secondary">No folders configured yet.</div>
}
} else {
<h4 class="mb-3"><i class="bi @_groups[_activeGroup].Icon"></i> @_activeGroup</h4>
@if (_groupedSettings.TryGetValue(_activeGroup, out var settings) && settings.Count > 0) {
<div class="settings-grid">
@foreach (var setting in settings) {
@switch (setting.DisplayType) {
case DisplayType.Switch:
<SettingSwitch Setting="setting"/>
break;
case DisplayType.IntNumber:
case DisplayType.FloatNumber:
<SettingNumber Setting="option"/>
<SettingNumber Setting="setting"/>
break;
case DisplayType.Checkbox:
// TODO: maybe decide if we ever need it?
case DisplayType.Switch:
<SettingSwitch Setting="option"/>
break;
case DisplayType.DateTimePicker:
<div class="">DATETIME NOT IMPLEMENTED Value: @option.Value</div>
case DisplayType.Text:
case DisplayType.Password:
<SettingString Setting="setting"/>
break;
case DisplayType.TimePicker:
<SettingRange Setting="option" MinValue="Convert.ToInt32(option.Options?[0])" MaxValue="Convert.ToInt32(option.Options?[1])" Step="Convert.ToInt32(option.Options?[2])"/>
<SettingRange Setting="setting"
MinValue="Convert.ToInt32(setting.Options?[0])"
MaxValue="Convert.ToInt32(setting.Options?[1])"
Step="Convert.ToInt32(setting.Options?[2])"/>
break;
case DisplayType.Po2W:
<SettingRange Setting="option" IsPowerOfTwo="true" MinValue="Convert.ToInt32(option.Options?[0])" MaxValue="Convert.ToInt32(option.Options?[1])" Step="Convert.ToInt32(option.Options?[2])"/>
<SettingRange Setting="setting" IsPowerOfTwo="true"
MinValue="Convert.ToInt32(setting.Options?[0])"
MaxValue="Convert.ToInt32(setting.Options?[1])"
Step="Convert.ToInt32(setting.Options?[2])"/>
break;
default:
<div class="alert alert-warning py-1 small">Unsupported: @setting.DisplayType</div>
break;
}
}
<div>
<button class="btn btn-outline-primary my-2" @onclick="OnSaveChanges"><i class="bi bi-floppy2 mx-1"></i>Save Changes</button>
</div>
} else {
<div class="alert alert-danger my-2 my-sm-5 mx-auto" style="max-width: fit-content" role="alert">
No settings available.
</div>
}
<hr/>
<h3>Folders</h3>
@if (folders.Any()) {
foreach (var folder in folders) {
<FolderBox Folder="folder"/>
} else {
<div class="alert alert-secondary">No settings in this category.</div>
}
<div>
<button class="btn btn-outline-primary my-2" @onclick="OnAddFolder"><i class="bi bi-folder-plus mx-1"></i>Add folder</button>
</div>
} else {
<div class="alert alert-danger my-2 my-sm-5 mx-auto" style="max-width: fit-content" role="alert">
No folders available.
</div>
}
}
</main>
</div>
@code {
List<SettingDto> settings = new();
List<FolderFullDto> folders = new();
private record GroupInfo(string Icon, string[] SettingNames);
protected override void OnInitialized() {
base.OnInitialized();
LoginService.LoggedUserChanged += async (_, _) => {
private static readonly Dictionary<string, GroupInfo> _groups = new() {
["General"] = new("bi-gear-wide-connected", ["User Registration Enabled"]),
["Scanning"] = new("bi-arrow-repeat", ["Folder Scan Enabled", "Folder Scan Interval"]),
["Upload"] = new("bi-upload", ["File Upload Enabled", "File Upload MaxSize"]),
["Paths"] = new("bi-folder2", ["Thumbnail Path", "Preview Path", "Thumbnail Size", "Preview Size"]),
["Jobs"] = new("bi-kanban-fill", ["Max Concurrent Jobs", "Job Retention Days"]),
["Folders"] = new("bi-folder2-open", [])
};
private Dictionary<string, List<SettingDto>> _groupedSettings = new();
private List<FolderFullDto> _folders = new();
private string _activeGroup = "General";
private bool _isLoading = true;
protected override async Task OnInitializedAsync() {
if (LoginService.IsLoggedIn) {
await LoadData();
} else {
LoginService.AuthInfoChanged += async (_, _) => {
await LoadData();
StateHasChanged();
};
}
protected async override Task OnInitializedAsync() {
if (LoginService.IsLoggedIn)
await LoadData();
}
async Task LoadData() {
settings = await SettingsService.GetAllSettings() ?? new List<SettingDto>();
folders = await FoldersService.GetAllFolders() ?? new List<FolderFullDto>();
_isLoading = true;
StateHasChanged();
var settingsTask = SettingsService.GetAllSettings();
var foldersTask = FoldersService.GetAllFolders();
await Task.WhenAll(settingsTask, foldersTask);
var settings = settingsTask.Result;
var folders = foldersTask.Result;
if (settings != null) {
_groupedSettings = GroupSettings(settings);
}
if (folders != null) {
_folders = folders;
}
_isLoading = false;
StateHasChanged();
}
void OnSaveChanges() {
SettingsService.OnBeginSave();
FoldersService.OnSaveFolders();
StateHasChanged();
static Dictionary<string, List<SettingDto>> GroupSettings(List<SettingDto> settings) {
var lookup = _groups
.Where(g => g.Key != "Folders")
.SelectMany(g => g.Value.SettingNames.Select(name => (Group: g.Key, Name: name)))
.ToDictionary(x => x.Name, x => x.Group);
return settings
.GroupBy(s => lookup.GetValueOrDefault(s.Name, "Other"))
.ToDictionary(g => g.Key, g => g.ToList());
}
void SelectGroup(string group) {
_activeGroup = group;
}
async Task OnAddFolder() {
await FoldersService.CreateFolderAsync(new FolderCreateDto() { BasePath = "New Folder" });
NavigationManager.Refresh(true);
await FoldersService.CreateFolderAsync(new FolderCreateDto { BasePath = "New Folder" });
await LoadData();
}
void HandleFolderDeleted(Guid id) {
_folders.RemoveAll(f => f.Id == id);
StateHasChanged();
}
}

View File

@@ -0,0 +1,55 @@
.settings-layout {
display: flex;
gap: 1.5rem;
min-height: calc(100vh - 80px);
padding: 1.5rem 0;
}
.settings-sidebar {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 180px;
padding: 0.5rem;
background: var(--bs-dark-bg-subtle);
border-radius: 0.5rem;
height: fit-content;
position: sticky;
top: 1.5rem;
}
.settings-nav-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: none;
background: none;
border-radius: 0.375rem;
color: var(--bs-body-color);
cursor: pointer;
transition: background 0.15s, color 0.15s;
font-size: 0.9rem;
width: 100%;
text-align: left;
}
.settings-nav-item:hover {
background: var(--bs-tertiary-bg);
}
.settings-nav-item.active {
background: var(--bs-primary);
color: #fff;
}
.settings-content {
flex: 1;
min-width: 0;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 1rem;
}

View File

@@ -1,92 +0,0 @@
@page "/Settings1"
@using Butter.Dtos.Folder
@using Butter.Dtos.Settings
@using Butter.Settings
@using Butter.Types
@using MilkStream.Client.Components.SettingBoxes
@using MilkStream.Client.Services
@using System.Linq
@inject NavigationManager NavigationManager
@inject LoginService LoginService
@inject SettingsService SettingsService
@inject FoldersService FoldersService
<h3>Settings</h3>
@if (areSettingsLoading || settings is {Count: 0}) {
<LoadSpinner/>
} else {
<div class="row row-cols-2">
<div class="col">
<!--Folder Scan Enabled-->
<SettingSwitch Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.FolderScanEnabled.AsString())"/>
</div>
<div class="col">
<!--Folder Scan Interval-->
<SettingRange Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.FolderScanInterval.AsString())" MinValue="5" MaxValue="360" Step="5"/>
</div>
<div class="col">
<!--File Upload Enabled-->
<SettingSwitch Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.FileUploadEnabled.AsString())"/>
</div>
<div class="col">
<!--File Upload Max Size-->
<SettingRange Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.FileUploadMaxSize.AsString())" MinValue="1024" MaxValue="1000000" Step="1000"/>
</div>
<div class="col">
<!--Thumbnail Path-->
<SettingString Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.ThumbnailPath.AsString())"/>
</div>
<div class="col">
<!--Thumbnail Size-->
<SettingRange Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.ThumbnailSize.AsString())" IsPowerOfTwo="true" MinValue="8" MaxValue="12" Step="1"/>
</div>
<div class="col">
<!--Previews Path-->
<SettingString Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.PreviewPath.AsString())"/>
</div>
<div class="col">
<!--Previews Size-->
<SettingRange Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.PreviewSize.AsString())" IsPowerOfTwo="true" MinValue="8" MaxValue="14" Step="1"/>
</div>
<div class="col">
<!--Max concurrent jobs -->
<SettingRange Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.MaxConcurrentJobs.AsString())" MinValue="1" MaxValue="32" Step="1"/>
</div>
<div class="col">
<!-- User registration enabled -->
<SettingSwitch Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.UserRegistrationEnabled.AsString())"/>
</div>
</div>
<div>
<button class="btn btn-outline-primary my-2" @onclick="SaveSettings"><i class="bi bi-floppy2 mx-1"></i>Save Changes</button>
</div>
}
<h3>Folders</h3>
@code {
bool areSettingsLoading = true;
List<SettingDto>? settings = new();
protected async override Task OnInitializedAsync() {
if (LoginService.IsLoggedIn) {
await LoadSettings();
} else {
LoginService.AuthInfoChanged += async (_, _) => {
await LoadSettings();
};
}
}
async Task LoadSettings() {
settings = await SettingsService.GetAllSettings();
if(settings != null) areSettingsLoading = false;
StateHasChanged();
}
void SaveSettings() {
SettingsService.OnBeginSave();
}
}

View File

@@ -7,16 +7,41 @@
[Parameter]
public required SettingDto? Setting { get; set; }
private CancellationTokenSource? _debounceCts;
protected bool IsSaving { get; set; }
protected bool IsSaved { get; set; }
protected virtual SettingDto? GetSetting() {
return Setting;
}
protected override void OnInitialized() {
SettingsService.BeginSave += async (sender, args) => {
protected async Task OnValueChanged() {
_debounceCts?.Cancel();
_debounceCts = new CancellationTokenSource();
var token = _debounceCts.Token;
try {
await Task.Delay(500, token);
token.ThrowIfCancellationRequested();
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
IsSaving = true;
await InvokeAsync(StateHasChanged);
await SettingsService.UpdateSettingAsync(setting);
IsSaving = false;
IsSaved = true;
await InvokeAsync(StateHasChanged);
await Task.Delay(2000);
IsSaved = false;
await InvokeAsync(StateHasChanged);
} catch (OperationCanceledException) {
// Debounce cancelled by another change
}
}
}

View File

@@ -8,27 +8,19 @@
<div class="card-header">@(Setting?.Name ?? "NO NAME")</div>
<div class="card-body d-flex justify-content-between align-items-center">
<p class="card-text">@(Setting?.Description ?? "No description provided")</p>
<div>
<input type="number" id="@Setting?.Name" class="form-control w-auto" @bind="currentValue" />
<div class="d-flex align-items-center gap-2">
<input type="number" id="@Setting?.Name" class="form-control w-auto" @bind="currentValue" @bind:after="() => OnValueChanged()" />
@SaveIndicator
</div>
</div>
</div>
@code {
private float currentValue;
protected override void OnInitialized() {
SettingsService.BeginSave += async (sender, args) => {
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
if (float.TryParse(Setting?.Value, out float value)) {
currentValue = value;
} else {
currentValue = 0; // Default value if parsing fails
}
}
@@ -37,4 +29,18 @@
Setting.Value = currentValue.ToString(CultureInfo.InvariantCulture);
return Setting;
}
private RenderFragment SaveIndicator => builder => {
if (IsSaving) {
builder.OpenElement(0, "small");
builder.AddAttribute(1, "class", "text-secondary");
builder.AddContent(2, "Saving...");
builder.CloseElement();
} else if (IsSaved) {
builder.OpenElement(0, "small");
builder.AddAttribute(1, "class", "text-success");
builder.AddContent(2, "Saved");
builder.CloseElement();
}
};
}

View File

@@ -5,39 +5,34 @@
<div class="card mb-3">
<div class="card-header">@(Setting?.Name ?? "NO NAME")</div>
<div class="card-body d-flex justify-content-between align-items-center">
<div class="card-body">
<p class="card-text">@(Setting?.Description ?? "No description provided")</p>
<div class="d-flex">
<div class="mx-2">@(IsPowerOfTwo ? Math.Pow(2, currentValue) : currentValue)</div>
<input type="range" id="@Setting?.Name" class="form-range" @bind="@currentValue" min="@MinValue" max="@MaxValue"/>
<div class="d-flex align-items-center gap-3 mt-2">
<span class="badge bg-secondary fs-6" style="min-width: 4.5rem;">@(IsPowerOfTwo ? Math.Pow(2, currentValue) : currentValue)</span>
<input type="range" id="@Setting?.Name" class="form-range flex-grow-1"
@bind:event="oninput" @bind="@currentValue" @bind:after="() => OnValueChanged()"
min="@MinValue" max="@MaxValue" step="@Step"/>
@SaveIndicator
</div>
</div>
</div>
@code {
[Parameter]
public int MinValue {get; set;}
public int MinValue { get; set; }
[Parameter]
public int MaxValue {get; set;} = 100;
public int MaxValue { get; set; } = 100;
[Parameter]
public bool IsPowerOfTwo {get; set;}
public bool IsPowerOfTwo { get; set; }
[Parameter]
public int Step {get; set;} = 1;
public int Step { get; set; } = 1;
private int currentValue;
protected override void OnInitialized() {
SettingsService.BeginSave += async (sender, args) => {
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
if (IsPowerOfTwo) {
var converted = int.TryParse(Setting?.Value, out int value);
currentValue = converted ? (int)Math.Log2(value) : 0; // Default value if parsing fails
currentValue = int.TryParse(Setting?.Value, out int value) ? (int)Math.Log2(value) : 0;
} else {
currentValue = int.TryParse(Setting?.Value, out int value) ? value : 0; // Default value if parsing fails
currentValue = int.TryParse(Setting?.Value, out int value) ? value : 0;
}
}
@@ -47,4 +42,17 @@
return Setting;
}
private RenderFragment SaveIndicator => builder => {
if (IsSaving) {
builder.OpenElement(0, "small");
builder.AddAttribute(1, "class", "text-secondary");
builder.AddContent(2, "Saving...");
builder.CloseElement();
} else if (IsSaved) {
builder.OpenElement(0, "small");
builder.AddAttribute(1, "class", "text-success");
builder.AddContent(2, "Saved");
builder.CloseElement();
}
};
}

View File

@@ -7,8 +7,9 @@
<div class="card-header">@(Setting?.Name ?? "NO NAME")</div>
<div class="card-body d-flex justify-content-between align-items-center">
<p class="card-text">@(Setting?.Description ?? "No description provided")</p>
<div class="d-flex">
<input type="text" id="@Setting?.Name" class="form-control" @bind="@currentValue" />
<div class="d-flex align-items-center gap-2">
<input type="text" id="@Setting?.Name" class="form-control" @bind="@currentValue" @bind:after="() => OnValueChanged()" />
@SaveIndicator
</div>
</div>
</div>
@@ -18,12 +19,6 @@
protected override void OnInitialized() {
base.OnInitialized();
SettingsService.BeginSave += async (sender, args) => {
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
currentValue = Setting?.Value ?? string.Empty;
}
@@ -32,4 +27,18 @@
Setting.Value = currentValue;
return Setting;
}
private RenderFragment SaveIndicator => builder => {
if (IsSaving) {
builder.OpenElement(0, "small");
builder.AddAttribute(1, "class", "text-secondary");
builder.AddContent(2, "Saving...");
builder.CloseElement();
} else if (IsSaved) {
builder.OpenElement(0, "small");
builder.AddAttribute(1, "class", "text-success");
builder.AddContent(2, "Saved");
builder.CloseElement();
}
};
}

View File

@@ -8,8 +8,11 @@
<div class="card-header">@(Setting?.Name ?? "NO NAME")</div>
<div class="card-body d-flex justify-content-between align-items-center">
<p class="card-text">@(Setting?.Description ?? "No description provided")</p>
<div class="d-flex align-items-center gap-2">
<div class="form-switch">
<input type="checkbox" id="@Setting?.Name" class="form-check-input m-1" @bind="isChecked"/>
<input type="checkbox" id="@Setting?.Name" class="form-check-input m-1" @bind="isChecked" @bind:after="() => OnValueChanged()"/>
</div>
@SaveIndicator
</div>
</div>
</div>
@@ -18,12 +21,6 @@
private bool isChecked;
protected override void OnInitialized() {
SettingsService.BeginSave += async (sender, args) => {
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
if (bool.TryParse(Setting?.Value, out var parsedValue)) {
isChecked = parsedValue;
}
@@ -34,4 +31,18 @@
Setting.Value = isChecked.ToString();
return Setting;
}
private RenderFragment SaveIndicator => builder => {
if (IsSaving) {
builder.OpenElement(0, "small");
builder.AddAttribute(1, "class", "text-secondary");
builder.AddContent(2, "Saving...");
builder.CloseElement();
} else if (IsSaved) {
builder.OpenElement(0, "small");
builder.AddAttribute(1, "class", "text-success");
builder.AddContent(2, "Saved");
builder.CloseElement();
}
};
}

View File

@@ -26,15 +26,6 @@ public sealed class SettingsService : AuthServiceBase {
Logger = logger;
}
/// <summary>
/// Fired when a save operation begins.
/// </summary>
public event EventHandler? BeginSave;
/// <summary>
/// Invokes the <see cref="BeginSave"/> event.
/// </summary>
public void OnBeginSave() => BeginSave?.Invoke(this, EventArgs.Empty);
/// <summary>
/// Gets all application settings.
/// </summary>

View File

@@ -20,8 +20,8 @@
volumes:
#- "/root/MilkyShots/storageImages:/diary:ro"
- "./storageImages:/diary:ro"
#- "/root/MilkyShots/thumbs:/app/thumbnails:rw"
#- "/root/MilkyShots/previews:/app/previews:rw"
- "thumbs:/app/thumbnails"
- "previews:/app/previews"
database:
#image: tensorchord/pgvecto-rs:pg17-v0.4.0
@@ -57,3 +57,5 @@
volumes:
pg_dbdata:
thumbs:
previews: