Compare commits
2 Commits
develop
...
3f79eb0b2e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f79eb0b2e | |||
| 4432c901f0 |
@@ -27,6 +27,7 @@ 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);
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ 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);
|
||||||
|
|
||||||
@@ -63,6 +65,25 @@ 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(@"<img\b(?<before>[^>]*?\bsrc=[""'])(?<src>[^""']*?)(?<after>[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
private static readonly Regex ImageTagRegex = new(@"(?<before><img\b[^>]*?\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,6 +27,9 @@ 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[^>]*>| |\s)*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
public EpubReaderService(
|
public EpubReaderService(
|
||||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||||
@@ -102,7 +105,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)) continue;
|
if (string.IsNullOrWhiteSpace(sanitizedContent) || EmptyBlockRegex.IsMatch(sanitizedContent)) continue;
|
||||||
|
|
||||||
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
|
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
|
||||||
|
|
||||||
@@ -236,7 +239,9 @@ public class EpubReaderService : IEpubReader
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(html)) return html;
|
if (string.IsNullOrEmpty(html)) return html;
|
||||||
|
|
||||||
return ImageTagRegex.Replace(html, match =>
|
var normalizedHtml = NormalizeSvgImageTags(html);
|
||||||
|
|
||||||
|
return ImageTagRegex.Replace(normalizedHtml, match =>
|
||||||
{
|
{
|
||||||
var rawSrc = match.Groups["src"].Value;
|
var rawSrc = match.Groups["src"].Value;
|
||||||
|
|
||||||
@@ -258,6 +263,31 @@ 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;
|
||||||
|
|||||||
@@ -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")">
|
<div class="nexus-unified-mobile-toolbar @(ThemeService.IsLightMode ? "theme-light" : "theme-dark") @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
|
||||||
<!-- 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,8 +112,11 @@
|
|||||||
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()
|
||||||
@@ -160,5 +163,6 @@
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||||
|
StateService.OnBarsHiddenChanged -= HandleBarsHiddenChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,16 @@
|
|||||||
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")">
|
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark") @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
|
||||||
@if (_isMobile && ViewModel != null)
|
@if (_isMobile && ViewModel != null)
|
||||||
{
|
{
|
||||||
<header class="nexus-mobile-reader-header">
|
<header class="nexus-mobile-reader-header @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
|
||||||
<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,6 +130,7 @@
|
|||||||
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;
|
||||||
@@ -250,7 +251,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-flow-container");
|
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-canvas");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -266,6 +267,17 @@
|
|||||||
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)
|
||||||
{
|
{
|
||||||
@@ -471,6 +483,7 @@
|
|||||||
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: 1.5rem;
|
gap: 0.75rem;
|
||||||
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,10 +69,21 @@
|
|||||||
.block-wrapper {
|
.block-wrapper {
|
||||||
transition: all 0.5s ease;
|
transition: all 0.5s ease;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px;
|
padding: 2px 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;
|
||||||
@@ -90,12 +101,24 @@
|
|||||||
/* 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: 1.5rem 0 1.5rem 0;
|
margin: 1rem 0 1rem 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;
|
||||||
@@ -116,7 +139,7 @@
|
|||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin: 2rem 0;
|
margin: 1.25rem 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);
|
||||||
@@ -344,9 +367,16 @@
|
|||||||
/* 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;
|
||||||
/* Safe breathing room */
|
gap: 0.75rem !important; /* Tighter spacing on mobile */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,8 +390,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: 1.5rem !important;
|
margin-top: 0.5rem !important; /* Tighter margins on mobile */
|
||||||
margin-bottom: 1rem !important;
|
margin-bottom: 0.25rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,8 +411,14 @@
|
|||||||
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);
|
||||||
@@ -567,4 +603,44 @@
|
|||||||
transform: rotate(0deg) scale(1);
|
transform: rotate(0deg) scale(1);
|
||||||
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,4 +11,7 @@ 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,6 +14,9 @@ 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
|
||||||
{
|
{
|
||||||
@@ -38,4 +41,23 @@ 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,6 +26,7 @@ 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;
|
||||||
@@ -44,6 +45,17 @@ 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;
|
||||||
});
|
});
|
||||||
@@ -60,3 +72,4 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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;
|
||||||
@@ -121,6 +122,17 @@ 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";
|
||||||
|
|||||||
@@ -267,6 +267,74 @@ 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> </p>")]
|
||||||
|
[InlineData("<p> <br> </p>")]
|
||||||
|
[InlineData("<br>")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user