feat(auth): Stabilize authentication flow and implement premium hydration preloader #60

Merged
mjasin merged 7 commits from feat/mobile-ux-overhaul into infra/beta-deploy-test 2026-05-27 10:29:53 +00:00
5 changed files with 129 additions and 11 deletions
Showing only changes of commit ee87014fee - Show all commits
@@ -82,6 +82,7 @@
private bool _isInteractive;
private string? _currentActiveBlockId;
private bool _isMobile = false;
private DotNetObjectReference<ReaderCanvas>? _selfReference;
protected override async Task OnInitializedAsync()
{
@@ -130,6 +131,8 @@
{
await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId);
}
await InitViewportDetectionAsync();
}
if (ViewModel != null && !_isJsInitialized)
@@ -140,6 +143,44 @@
}
}
private async Task InitViewportDetectionAsync()
{
try
{
_selfReference = DotNetObjectReference.Create(this);
var isMobileViewport = await JS.InvokeAsync<bool>("eval", "window.innerWidth < 768");
await OnViewportChanged(isMobileViewport);
await JS.InvokeVoidAsync("eval", @"
window.registerCanvasViewportObserver = (dotNetHelper) => {
let currentIsMobile = window.innerWidth < 768;
window.addEventListener('resize', () => {
let isMobile = window.innerWidth < 768;
if (isMobile !== currentIsMobile) {
currentIsMobile = isMobile;
dotNetHelper.invokeMethodAsync('OnViewportChanged', isMobile);
}
});
}
");
await JS.InvokeVoidAsync("registerCanvasViewportObserver", _selfReference);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to initialize viewport detection in ReaderCanvas.");
}
}
[JSInvokable]
public async Task OnViewportChanged(bool isMobile)
{
if (_isMobile != isMobile)
{
_isMobile = isMobile;
await InvokeAsync(StateHasChanged);
}
}
private async Task InitializeSelectionListenerAsync()
{
try
@@ -326,5 +367,6 @@
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
InteractionService.OnTextSelected -= HandleTextSelected;
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
_selfReference?.Dispose();
}
}
@@ -282,6 +282,7 @@
private string _platformClass = "platform-desktop";
private bool _isMobile = false;
private DotNetObjectReference<ReaderLayout>? _selfReference;
protected override void OnInitialized()
{
@@ -370,6 +371,47 @@
{
Logger.LogError(ex, "Failed to initialize layout resizer JS module.");
}
await InitViewportDetectionAsync();
}
}
private async Task InitViewportDetectionAsync()
{
try
{
_selfReference = DotNetObjectReference.Create(this);
var isMobileViewport = await JS.InvokeAsync<bool>("eval", "window.innerWidth < 768");
await OnViewportChanged(isMobileViewport);
await JS.InvokeVoidAsync("eval", @"
window.registerViewportObserver = (dotNetHelper) => {
let currentIsMobile = window.innerWidth < 768;
window.addEventListener('resize', () => {
let isMobile = window.innerWidth < 768;
if (isMobile !== currentIsMobile) {
currentIsMobile = isMobile;
dotNetHelper.invokeMethodAsync('OnViewportChanged', isMobile);
}
});
}
");
await JS.InvokeVoidAsync("registerViewportObserver", _selfReference);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to initialize viewport detection.");
}
}
[JSInvokable]
public async Task OnViewportChanged(bool isMobile)
{
if (_isMobile != isMobile)
{
_isMobile = isMobile;
_platformClass = _isMobile ? "platform-mobile" : "platform-desktop";
await InvokeAsync(StateHasChanged);
}
}
@@ -383,5 +425,6 @@
InteractionService.OnNodeSelected -= HandleNodeSelectedAsync;
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
_selfReference?.Dispose();
}
}
@@ -506,7 +506,7 @@ main {
}
.platform-mobile .nexus-mobile-reader-tabs {
display: block;
display: none; /* Keep hidden by default */
width: 100vw;
height: calc(100vh - 60px);
position: absolute;
@@ -517,6 +517,11 @@ main {
z-index: 15;
}
.app-container.platform-mobile.active-mobile-tab-graph .nexus-mobile-reader-tabs,
.app-container.platform-mobile.active-mobile-tab-insight .nexus-mobile-reader-tabs {
display: block; /* Show only when graph or insight tabs are active */
}
.nexus-mobile-tab-content {
display: none;
width: 100%;
@@ -311,6 +311,8 @@ export function mount(containerId, data, dotNetHelper) {
if (node) {
node.attr("transform", d => {
if (d.x === undefined || isNaN(d.x) || !isFinite(d.x)) d.x = width / 2;
if (d.y === undefined || isNaN(d.y) || !isFinite(d.y)) d.y = height / 2;
// Keep within bounds with padding
const pillWidth = getPillWidth(d);
const halfWidth = pillWidth / 2;
@@ -341,10 +343,12 @@ export function updateData(data) {
// Keep existing node positions if they match by ID
const oldNodes = new Map(simulation.nodes().map(d => [d.id, d]));
data.nodes.forEach(d => {
if (d.x !== undefined && (!isFinite(d.x) || isNaN(d.x))) d.x = undefined;
if (d.y !== undefined && (!isFinite(d.y) || isNaN(d.y))) d.y = undefined;
if (oldNodes.has(d.id)) {
const old = oldNodes.get(d.id);
d.x = old.x;
d.y = old.y;
if (old.x !== undefined && isFinite(old.x) && !isNaN(old.x)) d.x = old.x;
if (old.y !== undefined && isFinite(old.y) && !isNaN(old.y)) d.y = old.y;
d.vx = old.vx;
d.vy = old.vy;
}
@@ -471,6 +475,7 @@ export function setActiveNode(nodeId) {
const firstMatch = targetNode.filter((d, i) => i === 0);
const d = firstMatch.datum();
if (!d || d.x === undefined || d.y === undefined || isNaN(d.x) || !isFinite(d.x) || isNaN(d.y) || !isFinite(d.y)) return;
// Reset all active classes
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
@@ -539,8 +544,14 @@ export function handleResize(containerId) {
const container = document.getElementById(containerId);
if (!container || !svgElement || !simulation) return;
width = container.clientWidth;
height = container.clientHeight;
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
// If container is hidden (size is 0), skip resize to avoid collapsing coordinates to (0,0) or NaN
if (newWidth <= 0 || newHeight <= 0) return;
width = newWidth;
height = newHeight;
svgElement.attr("viewBox", [0, 0, width, height]);
simulation.force("center", d3.forceCenter(width / 2, height / 2));
@@ -585,21 +596,26 @@ export function zoomReset() {
export function zoomToFit() {
if (!node || node.empty() || !svgElement || !zoomBehavior) return;
if (width <= 0 || height <= 0 || isNaN(width) || isNaN(height)) return;
// Get the actual bounding box of the nodes
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
node.each(d => {
const pw = getPillWidth(d) / 2;
minX = Math.min(minX, d.x - pw);
maxX = Math.max(maxX, d.x + pw);
minY = Math.min(minY, d.y - 15);
maxY = Math.max(maxY, d.y + 15);
if (d && d.x !== undefined && d.y !== undefined && isFinite(d.x) && isFinite(d.y)) {
const pw = getPillWidth(d) / 2;
minX = Math.min(minX, d.x - pw);
maxX = Math.max(maxX, d.x + pw);
minY = Math.min(minY, d.y - 15);
maxY = Math.max(maxY, d.y + 15);
}
});
if (minX === Infinity) return;
if (minX === Infinity || maxX === minX || maxY === minY) return;
const graphWidth = maxX - minX;
const graphHeight = maxY - minY;
if (graphWidth <= 0 || graphHeight <= 0 || isNaN(graphWidth) || isNaN(graphHeight)) return;
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
@@ -610,6 +626,8 @@ export function zoomToFit() {
1.2 // Max scale
);
if (isNaN(scale) || !isFinite(scale) || scale <= 0) return;
svgElement.transition().duration(750).call(
zoomBehavior.transform,
d3.zoomIdentity
+10
View File
@@ -52,6 +52,7 @@ builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new
builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService());
builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository());
builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepository());
builder.Services.AddSingleton<IConceptsMapReadRepository>(new ThrowingConceptsMapReadRepository());
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor());
@@ -104,6 +105,14 @@ public class ThrowingQuizResultRepository : IQuizResultRepository
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
}
public class ThrowingConceptsMapReadRepository : IConceptsMapReadRepository
{
private const string ErrorMessage = "ConceptsMap repository operations are not supported in the WASM client. Use the API endpoint for data access.";
public Task<string?> GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
public Task<List<KnowledgeUnit>> GetKnowledgeUnitsForBookAsync(Guid bookId, string tenantId, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
}
public class ThrowingSyncBroadcaster : ISyncBroadcaster
{
public Task BroadcastProgressAsync(string userId, string pageId, DateTime timestamp, string? excludedConnectionId, CancellationToken cancellationToken = default)
@@ -118,3 +127,4 @@ public class ThrowingEpubExtractor : IEpubExtractor
public Task<FluentResults.Result<List<string>>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("EPUB text extraction is not supported in the WASM client.");
}