Files
marechai/Marechai/Pages/Books/View.razor.cs
2026-06-07 20:12:43 +01:00

337 lines
13 KiB
C#

/******************************************************************************
// MARECHAI: Master repository of computing history artifacts information
// ----------------------------------------------------------------------------
//
// Author(s) : Natalia Portillo <claunia@claunia.com>
//
// --[ License ] --------------------------------------------------------------
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// ----------------------------------------------------------------------------
// Copyright © 2003-2026 Natalia Portillo
*******************************************************************************/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Marechai.ApiClient.Models;
using Marechai.Data;
using Marechai.Helpers;
using Marechai.Pages.Suggestions;
using Marechai.Shared;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using MudBlazor;
namespace Marechai.Pages.Books;
public partial class View
{
BookDto _book;
List<CompanyByBookDto> _companies;
long _lastId;
bool _isCollected;
PhotoLightbox _lightbox;
bool _loaded;
List<BookByMachineFamilyDto> _machineFamilies;
List<BookByMachineDto> _computers;
List<BookByMachineDto> _consoles;
List<BookByMachineDto> _smartphones;
List<BookByMachineDto> _tablets;
List<BookByMachineDto> _pdas;
List<PersonByBookDto> _people;
BookDto _previousBook;
BookDto _sourceBook;
DocumentSynopsisDto _synopsis;
string _descriptionLanguageServed;
bool _descriptionFellBack;
bool _hasAnyDescription;
HashSet<string> _existingDescriptionLangs = new(StringComparer.Ordinal);
HashSet<string> _pendingDescriptionLangs = new(StringComparer.Ordinal);
bool _togglingCollection;
[CascadingParameter]
Task<AuthenticationState> AuthState { get; set; }
[Parameter]
public long Id { get; set; }
protected override void OnParametersSet()
{
if(Id == _lastId) return;
_lastId = Id;
_loaded = false;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if(_loaded) return;
if(Id <= 0)
{
_loaded = true;
return;
}
// Single consolidated fetch: head + previous/source titles + synopsis +
// all four child collections (people, companies, machines, machine
// families) in one HTTP round-trip. Replaces 6 sequential service
// calls plus the two conditional Previous/Source book fetches that
// used to dominate the page-load critical path.
BookFullDto full = await Service.GetBookFullAsync(Id, UiLanguage.GetIso639_3());
if(full?.Book is null)
{
_loaded = true;
StateHasChanged();
return;
}
_book = full.Book;
_people = full.People ?? [];
_companies = full.Companies ?? [];
_machineFamilies = full.MachineFamilies ?? [];
List<BookByMachineDto> machines = full.Machines ?? [];
_computers = machines.Where(m => m.Type == (int)MachineType.Computer) .ToList();
_consoles = machines.Where(m => m.Type == (int)MachineType.Console) .ToList();
_smartphones = machines.Where(m => m.Type == (int)MachineType.Smartphone).ToList();
_tablets = machines.Where(m => m.Type == (int)MachineType.Tablet) .ToList();
_pdas = machines.Where(m => m.Type == (int)MachineType.Pda) .ToList();
// Load the per-language synopsis state separately so we can detect language
// fallback (the BookFullDto only carries the rendered synopsis text). The
// existing-languages HashSet feeds the language picker chips.
await LoadDescriptionStateAsync();
// Materialize lightweight stand-ins for the previous/source book chips.
// The markup only renders the link target (Id) and the button label
// (Title), so projecting just those two fields server-side is enough.
if(_book.PreviousId.HasValue && full.PreviousBookTitle is not null)
_previousBook = new BookDto { Id = _book.PreviousId.Value, Title = full.PreviousBookTitle };
if(_book.SourceId.HasValue && full.SourceBookTitle is not null)
_sourceBook = new BookDto { Id = _book.SourceId.Value, Title = full.SourceBookTitle };
AuthenticationState authState = await AuthState;
if(authState.User.Identity?.IsAuthenticated == true)
_isCollected = await CollectionSvc.IsBookCollectedAsync(Id);
_loaded = true;
StateHasChanged();
}
/// <summary>
/// Load the synopsis for the current UI language with English fallback, plus the
/// metadata (which languages already have a synopsis) used to drive the picker
/// status chips. Sets <see cref="_descriptionFellBack" /> when the served language
/// differs from the requested UI language.
/// </summary>
async Task LoadDescriptionStateAsync()
{
string requested = UiLanguage.GetIso639_3();
DocumentSynopsisDto served = await Service.GetSynopsisAsync(Id, requested);
_synopsis = served;
_descriptionLanguageServed = served?.LanguageCode;
_hasAnyDescription = served is not null && !string.IsNullOrWhiteSpace(served.Text);
_descriptionFellBack = _hasAnyDescription
&& _descriptionLanguageServed is not null
&& !string.Equals(_descriptionLanguageServed, requested, StringComparison.Ordinal);
// Lightweight metadata fetch so the picker can show "Has synopsis" / "Empty" chips.
List<DocumentSynopsisDto> all = await Service.GetSynopsesAsync(Id);
_existingDescriptionLangs = new HashSet<string>(StringComparer.Ordinal);
foreach(DocumentSynopsisDto d in all ?? new List<DocumentSynopsisDto>())
{
if(!string.IsNullOrEmpty(d.LanguageCode))
_existingDescriptionLangs.Add(d.LanguageCode);
}
// If the picker had no served synopsis but other languages exist, the page should
// still go down the "fallback / pencil" branch rather than the "no synopsis" branch.
if(!_hasAnyDescription && _existingDescriptionLangs.Count > 0)
_hasAnyDescription = true;
}
/// <summary>
/// Open the language picker. On selection, opens the markdown editor pre-loaded with
/// the existing synopsis for that language (or empty for a new translation). Refreshes
/// the synopsis card on success so the user immediately sees their pending suggestion's
/// status (the synopsis itself only updates when the admin accepts).
/// </summary>
async Task OpenDescriptionPickerAsync()
{
if(_book is null) return;
// Lesson 1: capture the route parameter Id, NOT _book.Id.Value. Async loads can leave
// _book.Id null/0 in race scenarios and the cast silently produces 0 → server treats it
// as an addition request and rejects with 400.
long bookId = Id;
// Refresh per-user pending list lazily so anonymous users never hit /auth/me/suggestions.
await RefreshPendingDescriptionLangsAsync();
var pickerParams = new DialogParameters
{
["ExistingLanguages"] = _existingDescriptionLangs,
["PendingLanguages"] = _pendingDescriptionLangs,
["DefaultLanguage"] = UiLanguage.GetIso639_3()
};
var pickerOptions = new DialogOptions
{
CloseOnEscapeKey = true,
FullWidth = true,
MaxWidth = MaxWidth.Small
};
var pickerRef = await DialogService.ShowAsync<LanguagePickerDialog>(
L["Choose language for synopsis"], pickerParams, pickerOptions);
var pickerResult = await pickerRef.Result;
if(pickerResult.Canceled || pickerResult.Data is not string langCode) return;
// Pre-fetch the existing markdown for the chosen language (may be null/empty).
DocumentSynopsisDto existing = await Service.GetSynopsisAsync(bookId, langCode);
bool isEdit = existing is not null && string.Equals(existing.LanguageCode, langCode, StringComparison.Ordinal);
string initialMd = isEdit ? (existing?.Text ?? string.Empty) : string.Empty;
var editorParams = new DialogParameters
{
["EntityType"] = SuggestionEntityType.BookSynopsis,
["EntityId"] = bookId,
["Subkey"] = langCode,
["EntityDisplayName"] = _book.Title ?? $"#{bookId}",
["LanguageDisplayName"] = LanguageDisplayName(langCode),
["InitialMarkdown"] = initialMd,
["IsEdit"] = isEdit
};
var editorOptions = new DialogOptions
{
CloseOnEscapeKey = true,
FullWidth = true,
MaxWidth = MaxWidth.Large
};
var editorRef = await DialogService.ShowAsync<MarkdownSuggestionDialog>(
L["Suggest synopsis"], editorParams, editorOptions);
var editorResult = await editorRef.Result;
if(!editorResult.Canceled && editorResult.Data is not null)
{
// Add the just-submitted language to the pending set so a follow-up click on the
// same language disables the button without a round-trip.
_pendingDescriptionLangs.Add(langCode);
StateHasChanged();
}
}
async Task RefreshPendingDescriptionLangsAsync()
{
AuthenticationState auth = await AuthState;
if(!(auth.User?.Identity?.IsAuthenticated ?? false))
{
_pendingDescriptionLangs.Clear();
return;
}
List<SuggestionDto> mine = await Suggestions.GetMyAsync();
var fresh = new HashSet<string>(StringComparer.Ordinal);
foreach(SuggestionDto s in mine ?? new List<SuggestionDto>())
{
if(s.EntityType == (int?)SuggestionEntityType.BookSynopsis
&& s.EntityId == Id
&& s.Status == (int?)SuggestionStatus.Pending
&& !string.IsNullOrEmpty(s.Subkey))
fresh.Add(s.Subkey);
}
_pendingDescriptionLangs = fresh;
}
static string LanguageDisplayName(string iso639_3) => iso639_3 switch
{
"eng" => "English",
"spa" => "Spanish",
"deu" => "German",
"fra" => "French",
"ita" => "Italian",
"por" => "Portuguese",
_ => iso639_3
};
/// <summary>
/// Open the entity-edit suggestion dialog for the Book. Lets any authenticated user
/// suggest changes to scalar Book fields AND atomic add/remove operations on Book's 4
/// junction tables (People, Companies, Machines, MachineFamilies). Each operation is
/// accepted or rejected independently by an admin.
/// </summary>
async Task OpenBookSuggestionDialogAsync()
{
if(_book is null) return;
// Lesson 1: capture the route parameter Id, NOT _book.Id.Value. Async loads can leave
// _book.Id null/0 in race scenarios and the cast silently produces 0 → server treats it
// as an addition request and rejects with 400.
long bookId = Id;
var dlgParams = new DialogParameters
{
["EntityId"] = bookId,
["CurrentDto"] = _book
};
var dlgOptions = new DialogOptions
{
CloseOnEscapeKey = true,
FullWidth = true,
MaxWidth = MaxWidth.Large
};
var dlgRef = await DialogService.ShowAsync<BookSuggestionDialog>(
L["Suggest changes"], dlgParams, dlgOptions);
// Match Machine View behaviour: don't refetch on success — the snackbar inside the
// dialog provides feedback, and the suggestion is invisible to the public page until
// an admin accepts it.
await dlgRef.Result;
}
async Task ToggleCollectionAsync()
{
_togglingCollection = true;
if(_isCollected)
{
(bool success, _) = await CollectionSvc.RemoveBookFromCollectionAsync(Id);
if(success) _isCollected = false;
}
else
{
(bool success, _) = await CollectionSvc.AddBookToCollectionAsync(Id);
if(success) _isCollected = true;
}
_togglingCollection = false;
}
}