14 Commits

Author SHA1 Message Date
mjasin b74ba4ba54 fix(creator): resolve editor duplication and theme synchronization issues 2026-06-15 19:06:31 +02:00
mjasin 4aa3b4b421 feat: support stopping only the web container in run-stage.sh and update GEMINI.md rules 2026-06-14 19:05:49 +02:00
mjasin 431d815f55 docs: document --nexus-only switch in GEMINI.md 2026-06-14 19:02:51 +02:00
mjasin 08905a248d feat(stage): add --nexus-only switch to run-stage.sh and execute premium creator edit layout polish 2026-06-14 19:01:41 +02:00
mjasin a738a28eb4 feat(health): add custom DB, Qdrant, and Neo4j health check services and secure Qdrant in staging 2026-06-14 15:15:58 +02:00
mjasin d2410e9793 chore(stage): fix bash arithmetic syntax in run-stage.sh 2026-06-14 15:09:58 +02:00
mjasin bb861e469f chore(stage): increase startup wait timeout to prevent false-positive warnings 2026-06-14 15:02:27 +02:00
mjasin ecabe01be0 fix(docker): install libgssapi-krb5-2 package to resolve missing GSSAPI/Kerberos library error 2026-06-14 15:01:34 +02:00
mjasin 00ebee8628 feat(creator): retire old workspace and polish CreatorEdit route & dashboard navigation 2026-06-14 11:42:34 +02:00
mjasin 042dad0774 feat(creator): Add polished single-chapter editor workspace page under /creator/edit/{BookId}/{ChapterId}
- Implementedfullscreen wrapper layout to eliminate browser scrollbar bugs
- Styled matte finish sidebar with active chapter indicators
- Standardized header alignment with clean monospace ID badge
- Integrated footer panel for state save indicators and premium action triggers
2026-06-14 11:25:12 +02:00
mjasin 893fed4d60 style(dashboard): Add top margin to ContextualRecommendationsWidget to match page layout spacing 2026-06-14 11:13:41 +02:00
mjasin 8856fb1614 feat(creator): Refactor Creator flow, implement book creation pipeline & versioning, and setup Docker staging
- Relocate dashboard routing to /creator and editor workspace to /creator/edit/{BookId}
- Implement CreateBookCommand and handler with transactional default chapter seeding
- Implement PublishBookVersionCommand and GetCreatorDashboardDataQuery
- Build CreatorDashboard modal and UI components with customized dark input styles
- Add run-stage.sh script to automate staging environment setup, database migrations, and health checks
- Update developer workflow rules in GEMINI.md
2026-06-14 10:58:37 +02:00
mjasin 978485e8ff feat: implement debounced autosave with strict LocalStorage garbage collection (Stage 2 Task B) 2026-06-11 20:33:59 +02:00
mjasin 155bfa9aa0 feat: implement secure image upload pipeline and backend XSS guard (Stage 2 Task A) 2026-06-11 20:32:05 +02:00
16 changed files with 61 additions and 337 deletions
@@ -38,7 +38,7 @@ public class PublishBookVersionCommandHandler : ICommandHandler<PublishBookVersi
if (book == null) if (book == null)
{ {
return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found.")); throw new BookNotFoundException(request.BookId);
} }
var oldDraftRevision = book.CurrentDraftRevision; var oldDraftRevision = book.CurrentDraftRevision;
@@ -41,7 +41,7 @@ public class GetBookRevisionsQueryHandler : IQueryHandler<GetBookRevisionsQuery,
if (!bookExists) if (!bookExists)
{ {
return FluentResults.Result.Fail<List<CreatorBookRevisionDto>>(new FluentResults.Error($"Book with ID '{request.BookId}' was not found.")); throw new BookNotFoundException(request.BookId);
} }
// Fetch all revisions sorted chronologically // Fetch all revisions sorted chronologically
@@ -27,7 +27,6 @@ public class BookStorageService : IBookStorageService
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads"); var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads");
EnsureDirectoryExists(uploadsFolder); EnsureDirectoryExists(uploadsFolder);
fileName = SanitizeFileName(fileName);
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
var filePath = Path.Combine(uploadsFolder, uniqueFileName); var filePath = Path.Combine(uploadsFolder, uniqueFileName);
@@ -53,7 +52,6 @@ public class BookStorageService : IBookStorageService
var coversFolder = Path.Combine(_environment.WebRootPath, "covers"); var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
EnsureDirectoryExists(coversFolder); EnsureDirectoryExists(coversFolder);
fileName = SanitizeFileName(fileName);
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
var filePath = Path.Combine(coversFolder, uniqueFileName); var filePath = Path.Combine(coversFolder, uniqueFileName);
@@ -65,25 +63,6 @@ public class BookStorageService : IBookStorageService
return $"covers/{uniqueFileName}"; return $"covers/{uniqueFileName}";
} }
private string SanitizeFileName(string fileName)
{
if (string.IsNullOrEmpty(fileName)) return fileName;
var sanitized = fileName
.Replace('\u00A0', ' ')
.Replace('\u2007', ' ')
.Replace('\u200B', ' ')
.Replace('\u202F', ' ');
var invalidChars = Path.GetInvalidFileNameChars();
foreach (var c in invalidChars)
{
sanitized = sanitized.Replace(c, '_');
}
return sanitized;
}
private void EnsureDirectoryExists(string path) private void EnsureDirectoryExists(string path)
{ {
if (!Directory.Exists(path)) if (!Directory.Exists(path))
@@ -18,7 +18,7 @@ public class EpubReaderService : IEpubReader
private readonly ILogger<EpubReaderService> _logger; private readonly ILogger<EpubReaderService> _logger;
private const int WordThreshold = 1000; private const int WordThreshold = 1000;
private static readonly Regex ImageTagRegex = new(@"(?<before><img\b[^>]*?\bsrc=[""'])(?<src>[^""']*?)(?<after>[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex ImageTagRegex = new(@"<img\b(?<before>[^>]*?\bsrc=[""'])(?<src>[^""']*?)(?<after>[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex BodyMatchRegex = new(@"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); private static readonly Regex BodyMatchRegex = new(@"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex ParagraphMatchRegex = new(@"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>|<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); private static readonly Regex ParagraphMatchRegex = new(@"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>|<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex StyleScriptRegex = new(@"<(style|script)\b[^>]*>.*?</\1>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); private static readonly Regex StyleScriptRegex = new(@"<(style|script)\b[^>]*>.*?</\1>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
@@ -27,9 +27,6 @@ public class EpubReaderService : IEpubReader
private static readonly Regex ImgTagSanitizerRegex = new(@"<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex ImgTagSanitizerRegex = new(@"<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex SrcAttributeRegex = new(@"\bsrc=[""'](?<src>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex SrcAttributeRegex = new(@"\bsrc=[""'](?<src>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex AltAttributeRegex = new(@"\balt=[""'](?<alt>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex AltAttributeRegex = new(@"\balt=[""'](?<alt>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex SvgImageTagRegex = new(@"<image\b(?<attrs>[^>]*?)>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex HrefAttributeRegex = new(@"\b(xlink:)?href=[""'](?<href>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex EmptyBlockRegex = new(@"^(</?(p|h[1-6]|ul|ol|li|blockquote|pre|div|span|br)\b[^>]*>|&nbsp;|\s)*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public EpubReaderService( public EpubReaderService(
IDbContextFactory<AppDbContext> dbContextFactory, IDbContextFactory<AppDbContext> dbContextFactory,
@@ -105,7 +102,7 @@ public class EpubReaderService : IEpubReader
foreach (var p in paragraphs) foreach (var p in paragraphs)
{ {
var sanitizedContent = SanitizeParagraph(p); var sanitizedContent = SanitizeParagraph(p);
if (string.IsNullOrWhiteSpace(sanitizedContent) || EmptyBlockRegex.IsMatch(sanitizedContent)) continue; if (string.IsNullOrWhiteSpace(sanitizedContent)) continue;
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent)); blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
@@ -239,9 +236,7 @@ public class EpubReaderService : IEpubReader
{ {
if (string.IsNullOrEmpty(html)) return html; if (string.IsNullOrEmpty(html)) return html;
var normalizedHtml = NormalizeSvgImageTags(html); return ImageTagRegex.Replace(html, match =>
return ImageTagRegex.Replace(normalizedHtml, match =>
{ {
var rawSrc = match.Groups["src"].Value; var rawSrc = match.Groups["src"].Value;
@@ -263,31 +258,6 @@ public class EpubReaderService : IEpubReader
}); });
} }
private static string NormalizeSvgImageTags(string html)
{
if (string.IsNullOrEmpty(html)) return html;
return SvgImageTagRegex.Replace(html, match =>
{
var attrs = match.Groups["attrs"].Value;
if (SrcAttributeRegex.IsMatch(attrs))
{
return $"<img {attrs}>";
}
var hrefMatch = HrefAttributeRegex.Match(attrs);
if (hrefMatch.Success)
{
var hrefVal = hrefMatch.Groups["href"].Value;
var cleanedAttrs = HrefAttributeRegex.Replace(attrs, "");
return $"<img src=\"{hrefVal}\" {cleanedAttrs}>";
}
return match.Value;
});
}
private static string ResolveRelativePath(string basePath, string relativePath) private static string ResolveRelativePath(string basePath, string relativePath)
{ {
if (string.IsNullOrEmpty(relativePath)) return string.Empty; if (string.IsNullOrEmpty(relativePath)) return string.Empty;
@@ -43,7 +43,6 @@
private IJSObjectReference? _module; private IJSObjectReference? _module;
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper; private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
private string? _lastInitializedEditorId; private string? _lastInitializedEditorId;
private bool _disposed;
private enum SaveStatus private enum SaveStatus
{ {
@@ -351,7 +350,6 @@
// Cancel pending timers thread-safely // Cancel pending timers thread-safely
CancellationTokenSource? ctsToCancel = null; CancellationTokenSource? ctsToCancel = null;
CancellationToken token;
lock (_timerLock) lock (_timerLock)
{ {
if (_debounceCts != null) if (_debounceCts != null)
@@ -360,7 +358,6 @@
_debounceCts = null; _debounceCts = null;
} }
_debounceCts = new CancellationTokenSource(); _debounceCts = new CancellationTokenSource();
token = _debounceCts.Token; // Capture token synchronously under lock on UI thread
} }
if (ctsToCancel != null) if (ctsToCancel != null)
@@ -379,6 +376,13 @@
// Start 5-second idle debounce timer // Start 5-second idle debounce timer
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
CancellationToken token;
lock (_timerLock)
{
if (_debounceCts == null) return;
token = _debounceCts.Token;
}
try try
{ {
await Task.Delay(5000, token); await Task.Delay(5000, token);
@@ -397,7 +401,7 @@
private async Task TriggerAutosaveAsync(string markdown, CancellationToken token) private async Task TriggerAutosaveAsync(string markdown, CancellationToken token)
{ {
if (token.IsCancellationRequested || _disposed) return; if (token.IsCancellationRequested) return;
_status = SaveStatus.Saving; _status = SaveStatus.Saving;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -412,8 +416,6 @@
token token
); );
if (_disposed) return;
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
// Purge LocalStorage backup key on HTTP success // Purge LocalStorage backup key on HTTP success
@@ -429,12 +431,10 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
if (_disposed) return;
_status = SaveStatus.OfflineLocalBackup; _status = SaveStatus.OfflineLocalBackup;
Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}"); Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}");
} }
if (_disposed) return;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -477,7 +477,6 @@
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
_disposed = true;
try try
{ {
_cts.Cancel(); _cts.Cancel();
@@ -8,7 +8,7 @@
@inject IReaderStateService StateService @inject IReaderStateService StateService
@inject IThemeService ThemeService @inject IThemeService ThemeService
<div class="nexus-unified-mobile-toolbar @(ThemeService.IsLightMode ? "theme-light" : "theme-dark") @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")"> <div class="nexus-unified-mobile-toolbar @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
<!-- Tab 1: Progress (Postęp) --> <!-- Tab 1: Progress (Postęp) -->
<button class="nav-toggle-btn progress-btn" @onclick="ToggleCheckpoints" aria-label="Postęp" title="Rozdziały i checkpoints"> <button class="nav-toggle-btn progress-btn" @onclick="ToggleCheckpoints" aria-label="Postęp" title="Rozdziały i checkpoints">
<div class="progress-ring-wrapper"> <div class="progress-ring-wrapper">
@@ -112,11 +112,8 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
ThemeService.OnThemeChanged += HandleThemeChanged; ThemeService.OnThemeChanged += HandleThemeChanged;
StateService.OnBarsHiddenChanged += HandleBarsHiddenChanged;
} }
private Task HandleBarsHiddenChanged() => InvokeAsync(StateHasChanged);
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged); private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
private double GetDashOffset() private double GetDashOffset()
@@ -163,6 +160,5 @@
public void Dispose() public void Dispose()
{ {
ThemeService.OnThemeChanged -= HandleThemeChanged; ThemeService.OnThemeChanged -= HandleThemeChanged;
StateService.OnBarsHiddenChanged -= HandleBarsHiddenChanged;
} }
} }
@@ -13,16 +13,8 @@
box-sizing: border-box; box-sizing: border-box;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
overflow: visible; /* Critical to show elevated FAB */ overflow: visible; /* Critical to show elevated FAB */
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
} }
.nexus-unified-mobile-toolbar.immersive-zen-mode {
transform: translateY(calc(100% + 24px)) !important;
opacity: 0;
pointer-events: none;
}
/* Light Mode: Premium Paper Look */ /* Light Mode: Premium Paper Look */
.nexus-unified-mobile-toolbar.theme-light { .nexus-unified-mobile-toolbar.theme-light {
background: rgba(244, 241, 234, 0.9); background: rgba(244, 241, 234, 0.9);
@@ -20,10 +20,10 @@
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ILogger<ReaderCanvas> Logger @inject ILogger<ReaderCanvas> Logger
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark") @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")"> <div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
@if (_isMobile && ViewModel != null) @if (_isMobile && ViewModel != null)
{ {
<header class="nexus-mobile-reader-header @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")"> <header class="nexus-mobile-reader-header">
<button class="nexus-mobile-escape-btn" @onclick="HandleEscape" aria-label="Powrót do pulpitu"> <button class="nexus-mobile-escape-btn" @onclick="HandleEscape" aria-label="Powrót do pulpitu">
<NexusIcon Name="chevron-left" Size="18" /> <NexusIcon Name="chevron-left" Size="18" />
<span>Pulpit</span> <span>Pulpit</span>
@@ -130,7 +130,6 @@
ThemeService.OnThemeChanged += HandleThemeChanged; ThemeService.OnThemeChanged += HandleThemeChanged;
NavigationService.OnNavigationChanged += OnNavigationChanged; NavigationService.OnNavigationChanged += OnNavigationChanged;
QuizService.OnQuizUpdated += HandleUpdate; QuizService.OnQuizUpdated += HandleUpdate;
StateService.OnBarsHiddenChanged += HandleBarsHiddenChanged;
InteractionService.OnScrollToBlockRequested += HandleScrollRequested; InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested; InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
@@ -251,7 +250,7 @@
if (_selfReference != null) if (_selfReference != null)
{ {
await module.InvokeVoidAsync("initObserver", _selfReference, ".reader-flow-container", ".block-wrapper"); await module.InvokeVoidAsync("initObserver", _selfReference, ".reader-flow-container", ".block-wrapper");
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-canvas"); _scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-flow-container");
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -267,17 +266,6 @@
await InteractionService.NotifyScrollPercentChanged(percent); await InteractionService.NotifyScrollPercentChanged(percent);
} }
[JSInvokable]
public async Task HandleScrollDelta(bool hideBars)
{
if (StateService.IsBarsHidden != hideBars)
{
StateService.IsBarsHidden = hideBars;
}
}
private Task HandleBarsHiddenChanged() => InvokeAsync(StateHasChanged);
[JSInvokable] [JSInvokable]
public async Task HandleBlockReached(string blockId, string content) public async Task HandleBlockReached(string blockId, string content)
{ {
@@ -483,7 +471,6 @@
ThemeService.OnThemeChanged -= HandleThemeChanged; ThemeService.OnThemeChanged -= HandleThemeChanged;
NavigationService.OnNavigationChanged -= OnNavigationChanged; NavigationService.OnNavigationChanged -= OnNavigationChanged;
QuizService.OnQuizUpdated -= HandleUpdate; QuizService.OnQuizUpdated -= HandleUpdate;
StateService.OnBarsHiddenChanged -= HandleBarsHiddenChanged;
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested; InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested; InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
@@ -45,7 +45,7 @@
min-height: calc(100vh - 180px); min-height: calc(100vh - 180px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 1.5rem;
position: relative; position: relative;
padding: 3rem 4rem 15rem 4rem; padding: 3rem 4rem 15rem 4rem;
/* Large padding-bottom for reachability, plus comfortable side margins */ /* Large padding-bottom for reachability, plus comfortable side margins */
@@ -69,21 +69,10 @@
.block-wrapper { .block-wrapper {
transition: all 0.5s ease; transition: all 0.5s ease;
border-radius: 8px; border-radius: 8px;
padding: 2px 8px; padding: 8px;
border: 1px solid transparent; border: 1px solid transparent;
} }
/* Pull subsequent block closer to headings or bold exercise labels */
.block-wrapper:has(h1),
.block-wrapper:has(h2),
.block-wrapper:has(h3),
.block-wrapper:has(h4),
.block-wrapper:has(h5),
.block-wrapper:has(h6),
.block-wrapper:has(p > strong) {
margin-bottom: -0.25rem;
}
/* Typographic refinement for TextSegmentBlock */ /* Typographic refinement for TextSegmentBlock */
::deep .nexus-ebook { ::deep .nexus-ebook {
font-family: 'Merriweather', serif !important; font-family: 'Merriweather', serif !important;
@@ -101,24 +90,12 @@
/* Warm charcoal for legibility */ /* Warm charcoal for legibility */
} }
/* Reset default margins for elements within separate block-wrappers */
::deep .nexus-ebook p,
::deep .nexus-ebook h1,
::deep .nexus-ebook h2,
::deep .nexus-ebook h3,
::deep .nexus-ebook h4,
::deep .nexus-ebook h5,
::deep .nexus-ebook h6 {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
/* Callout Box styling for legacy blockquote segments */ /* Callout Box styling for legacy blockquote segments */
::deep .nexus-ebook blockquote { ::deep .nexus-ebook blockquote {
background-color: rgba(255, 255, 255, 0.02); background-color: rgba(255, 255, 255, 0.02);
border-left: 4px solid var(--nexus-neon); border-left: 4px solid var(--nexus-neon);
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
margin: 1rem 0 1rem 0; margin: 1.5rem 0 1.5rem 0;
border-radius: 0 8px 8px 0; border-radius: 0 8px 8px 0;
font-size: 1.05rem; font-size: 1.05rem;
color: #e2e8f0; color: #e2e8f0;
@@ -139,7 +116,7 @@
color: #e0e0e0; color: #e0e0e0;
padding: 1.25rem; padding: 1.25rem;
border-radius: 8px; border-radius: 8px;
margin: 1.25rem 0; margin: 2rem 0;
overflow-x: auto; overflow-x: auto;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
border-left: 4px solid var(--nexus-neon); border-left: 4px solid var(--nexus-neon);
@@ -367,16 +344,9 @@
/* Ensure content is clear of bottom toolbar */ /* Ensure content is clear of bottom toolbar */
} }
.reader-canvas.immersive-zen-mode {
padding-top: calc(10px + env(safe-area-inset-top, 0px)) !important;
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px)) !important;
}
.reader-flow-container { .reader-flow-container {
padding-left: 18px !important;
padding-right: 18px !important;
padding-bottom: 4rem; padding-bottom: 4rem;
gap: 0.75rem !important; /* Tighter spacing on mobile */ /* Safe breathing room */
} }
} }
@@ -390,8 +360,8 @@
::deep .nexus-ebook h1 { ::deep .nexus-ebook h1 {
font-size: 1.35rem !important; font-size: 1.35rem !important;
line-height: 1.4 !important; line-height: 1.4 !important;
margin-top: 0.5rem !important; /* Tighter margins on mobile */ margin-top: 1.5rem !important;
margin-bottom: 0.25rem !important; margin-bottom: 1rem !important;
} }
} }
@@ -411,14 +381,8 @@
padding: 0 1rem; padding: 0 1rem;
z-index: 1000; z-index: 1000;
box-sizing: border-box; box-sizing: border-box;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.nexus-mobile-reader-header.immersive-zen-mode {
transform: translateY(-100%);
}
.theme-light .nexus-mobile-reader-header { .theme-light .nexus-mobile-reader-header {
background: rgba(249, 249, 249, 0.8); background: rgba(249, 249, 249, 0.8);
border-bottom-color: rgba(0, 0, 0, 0.08); border-bottom-color: rgba(0, 0, 0, 0.08);
@@ -604,43 +568,3 @@
opacity: 1; opacity: 1;
} }
} }
/* Ebook Image Scaling, Alignment, and Separation Lines */
.block-wrapper:has(img) {
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 1rem 0;
margin: 0.5rem 0;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.theme-light .block-wrapper:has(img) {
border-top: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
::deep .nexus-ebook img {
width: 100%;
max-width: 100%;
max-height: 75vh;
height: auto;
display: block;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
opacity: 0.9;
transition: opacity 0.25s ease;
}
::deep .nexus-ebook img:hover {
opacity: 1;
}
.theme-light ::deep .nexus-ebook img {
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.1);
opacity: 1;
}
@@ -11,7 +11,4 @@ public interface IReaderStateService
List<string> CurrentCheckpoints { get; set; } List<string> CurrentCheckpoints { get; set; }
string CurrentBlockId { get; set; } string CurrentBlockId { get; set; }
MobileReaderTab ActiveTab { get; set; } MobileReaderTab ActiveTab { get; set; }
bool IsBarsHidden { get; set; }
event Func<Task>? OnBarsHiddenChanged;
} }
@@ -14,9 +14,6 @@ public sealed class ReaderStateService : IReaderStateService
private List<string> _checkpoints = new(); private List<string> _checkpoints = new();
private string _blockId = string.Empty; private string _blockId = string.Empty;
private MobileReaderTab _activeTab = MobileReaderTab.Reader; private MobileReaderTab _activeTab = MobileReaderTab.Reader;
private bool _barsHidden;
public event Func<Task>? OnBarsHiddenChanged;
public int CurrentScrollPercentage public int CurrentScrollPercentage
{ {
@@ -41,23 +38,4 @@ public sealed class ReaderStateService : IReaderStateService
get { lock (_lock) return _activeTab; } get { lock (_lock) return _activeTab; }
set { lock (_lock) _activeTab = value; } set { lock (_lock) _activeTab = value; }
} }
public bool IsBarsHidden
{
get { lock (_lock) return _barsHidden; }
set
{
bool changed;
lock (_lock)
{
changed = _barsHidden != value;
_barsHidden = value;
}
if (changed && OnBarsHiddenChanged != null)
{
_ = OnBarsHiddenChanged.Invoke();
}
}
}
} }
@@ -26,7 +26,6 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
if (!container) return null; if (!container) return null;
let isThrottled = false; let isThrottled = false;
let lastScrollTop = 0;
const onScroll = () => { const onScroll = () => {
if (isThrottled) return; if (isThrottled) return;
@@ -45,17 +44,6 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
// Ensure bounds // Ensure bounds
percentage = Math.max(0, Math.min(100, percentage)); percentage = Math.max(0, Math.min(100, percentage));
// Scroll delta detection:
// Hide bars on scroll down, show on scroll up. Force show when close to top.
const delta = scrollTop - lastScrollTop;
if (scrollTop <= 10) {
dotNetHelper.invokeMethodAsync('HandleScrollDelta', false);
} else if (Math.abs(delta) > 5) {
const hideBars = delta > 0;
dotNetHelper.invokeMethodAsync('HandleScrollDelta', hideBars);
}
lastScrollTop = scrollTop;
dotNetHelper.invokeMethodAsync('HandleScrollPercentChanged', percentage); dotNetHelper.invokeMethodAsync('HandleScrollPercentChanged', percentage);
isThrottled = false; isThrottled = false;
}); });
@@ -72,4 +60,3 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
} }
}; };
} }
+20 -26
View File
@@ -1,5 +1,4 @@
using NexusReader.Web.Components; using NexusReader.Web.Components;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using NexusReader.Application; using NexusReader.Application;
@@ -122,17 +121,6 @@ builder.Services.AddAuthentication(options =>
options.DefaultScheme = IdentityConstants.ApplicationScheme; options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme; options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
}) })
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.Authority = builder.Configuration["Jwt:Authority"] ?? "https://example.com/";
options.Audience = builder.Configuration["Jwt:Audience"] ?? "NexusReaderAPI";
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true
};
})
.AddGoogle(options => .AddGoogle(options =>
{ {
options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "placeholder-id"; options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "placeholder-id";
@@ -531,15 +519,18 @@ app.MapGet("/api/creator/books/{bookId:guid}/revisions", async (Guid bookId, Cla
var tenantId = user.FindFirstValue("TenantId") ?? "global"; var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId)); try
if (result.IsSuccess) return Results.Ok(result.Value);
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
{ {
return Results.NotFound(errorMsg); var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId));
if (result.IsSuccess) return Results.Ok(result.Value);
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
return Results.BadRequest(errorMsg);
}
catch (NexusReader.Domain.Exceptions.BookNotFoundException)
{
return Results.NotFound($"Book with ID '{bookId}' was not found.");
} }
return Results.BadRequest(errorMsg);
}).RequireAuthorization(); }).RequireAuthorization();
app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [FromQuery] string version, ClaimsPrincipal user, IMediator mediator) => app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [FromQuery] string version, ClaimsPrincipal user, IMediator mediator) =>
@@ -554,15 +545,18 @@ app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [Fro
return Results.BadRequest("Version string is required."); return Results.BadRequest("Version string is required.");
} }
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId)); try
if (result.IsSuccess) return Results.Ok();
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
{ {
return Results.NotFound(errorMsg); var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId));
if (result.IsSuccess) return Results.Ok();
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
return Results.BadRequest(errorMsg);
}
catch (NexusReader.Domain.Exceptions.BookNotFoundException)
{
return Results.NotFound($"Book with ID '{bookId}' was not found.");
} }
return Results.BadRequest(errorMsg);
}).RequireAuthorization(); }).RequireAuthorization();
app.MapPost("/api/creator/books", async ( app.MapPost("/api/creator/books", async (
@@ -169,7 +169,7 @@ public class PublishBookVersionTests : IDisposable
} }
[Fact] [Fact]
public async Task Handle_WithMismatchedTenantId_ReturnsFailure() public async Task Handle_WithMismatchedTenantId_ThrowsBookNotFoundException()
{ {
// Arrange // Arrange
var bookId = Guid.NewGuid(); var bookId = Guid.NewGuid();
@@ -210,16 +210,13 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act // Act & Assert
var result = await handler.Handle(command, CancellationToken.None); var action = () => handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
[Fact] [Fact]
public async Task Handle_WithMismatchedUserId_ReturnsFailure() public async Task Handle_WithMismatchedUserId_ThrowsBookNotFoundException()
{ {
// Arrange // Arrange
var bookId = Guid.NewGuid(); var bookId = Guid.NewGuid();
@@ -260,16 +257,13 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act // Act & Assert
var result = await handler.Handle(command, CancellationToken.None); var action = () => handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
[Fact] [Fact]
public async Task Handle_WithNonExistentBook_ReturnsFailure() public async Task Handle_WithNonExistentBook_ThrowsBookNotFoundException()
{ {
// Arrange // Arrange
var command = new PublishBookVersionCommand( var command = new PublishBookVersionCommand(
@@ -281,12 +275,9 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act // Act & Assert
var result = await handler.Handle(command, CancellationToken.None); var action = () => handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
public void Dispose() public void Dispose()
@@ -234,7 +234,7 @@ public class CreatorDashboardTests : IDisposable
} }
[Fact] [Fact]
public async Task GetBookRevisions_WithMismatchedUserOrTenant_ReturnsFailure() public async Task GetBookRevisions_WithMismatchedUserOrTenant_ThrowsBookNotFoundException()
{ {
// Arrange // Arrange
var userId = "creator-123"; var userId = "creator-123";
@@ -262,14 +262,12 @@ public class CreatorDashboardTests : IDisposable
// Act & Assert // Act & Assert
var queryMismatchedTenant = new GetBookRevisionsQuery(bookId, userId, "different-tenant"); var queryMismatchedTenant = new GetBookRevisionsQuery(bookId, userId, "different-tenant");
var resultTenant = await handler.Handle(queryMismatchedTenant, CancellationToken.None); var actionTenant = () => handler.Handle(queryMismatchedTenant, CancellationToken.None);
resultTenant.IsSuccess.Should().BeFalse(); await actionTenant.Should().ThrowAsync<BookNotFoundException>();
resultTenant.Errors.Should().Contain(e => e.Message.Contains("was not found"));
var queryMismatchedUser = new GetBookRevisionsQuery(bookId, "different-user", tenantId); var queryMismatchedUser = new GetBookRevisionsQuery(bookId, "different-user", tenantId);
var resultUser = await handler.Handle(queryMismatchedUser, CancellationToken.None); var actionUser = () => handler.Handle(queryMismatchedUser, CancellationToken.None);
resultUser.IsSuccess.Should().BeFalse(); await actionUser.Should().ThrowAsync<BookNotFoundException>();
resultUser.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
public void Dispose() public void Dispose()
@@ -267,74 +267,6 @@ public class EpubReaderServiceTests : IDisposable
colonResult.Errors.First().Message.Should().Contain("Invalid resource path"); colonResult.Errors.First().Message.Should().Contain("Invalid resource path");
} }
[Fact]
public void RewriteImageUrls_PreservesImgPrefix()
{
// Arrange
var method = typeof(EpubReaderService).GetMethod("RewriteImageUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
method.Should().NotBeNull();
var input = "<img class=\"epub_cover_page_img\" src=\"cover.jpg\" />";
var ebookId = Guid.NewGuid();
// Act
var result = (string)method.Invoke(null, new object[] { input, ebookId, "OEBPS/cover-page.xhtml" });
// Assert
result.Should().StartWith("<img class=\"epub_cover_page_img\" src=\"/api/epub/");
}
[Fact]
public void RewriteImageUrls_NormalizesSvgImageTags()
{
// Arrange
var method = typeof(EpubReaderService).GetMethod("RewriteImageUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
method.Should().NotBeNull();
var inputXlink = "<image xlink:href=\"images/fig1.jpg\" width=\"100%\" />";
var inputHref = "<image href=\"images/fig2.jpg\" />";
var ebookId = Guid.NewGuid();
// Act
var resultXlink = (string)method.Invoke(null, new object[] { inputXlink, ebookId, "OEBPS/chapter1.xhtml" });
var resultHref = (string)method.Invoke(null, new object[] { inputHref, ebookId, "OEBPS/chapter1.xhtml" });
// Assert
resultXlink.Should().Contain("<img src=\"/api/epub/");
resultXlink.Should().Contain("width=\"100%\"");
resultXlink.Should().NotContain("<image");
resultXlink.Should().NotContain("xlink:href");
resultHref.Should().Contain("<img src=\"/api/epub/");
resultHref.Should().NotContain("<image");
resultHref.Should().NotContain("href=");
}
[Theory]
[InlineData("<p><br /></p>")]
[InlineData("<p>&nbsp;</p>")]
[InlineData("<p> <br> </p>")]
[InlineData("<br>")]
[InlineData("&nbsp;")]
public void EmptyBlockRegex_MatchesEmptyBlocks(string input)
{
// Arrange
var field = typeof(EpubReaderService).GetField("EmptyBlockRegex", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
field.Should().NotBeNull();
var regex = (System.Text.RegularExpressions.Regex)field.GetValue(null);
regex.Should().NotBeNull();
var method = typeof(EpubReaderService).GetMethod("SanitizeParagraph", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
method.Should().NotBeNull();
// Act
var sanitized = (string)method.Invoke(null, new object[] { input });
var isMatch = regex.IsMatch(sanitized);
// Assert
isMatch.Should().BeTrue();
}
public void Dispose() public void Dispose()
{ {
_connection.Close(); _connection.Close();