Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f79eb0b2e | |||
| 4432c901f0 | |||
| c94e8f0acb |
@@ -26,6 +26,7 @@ RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseApp
|
|||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
|||||||
@@ -50,5 +50,5 @@ version: 1.0
|
|||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **Docker Lifecycle Management**
|
> **Docker Lifecycle Management**
|
||||||
> Before starting work, the Docker instance of the application must be stopped. After finishing work, a new version from the current branch should be pushed to Docker and the instance restarted.
|
> Before starting work, only the web (nexus) container needs to be stopped to prevent port/application conflicts (e.g., `./run-stage.sh --stop --nexus-only` or `-s -n`); database containers (PostgreSQL, Neo4j, Qdrant) should continue to run to support local development/debugging. After finishing work, a new version of the web container from the current branch should be rebuilt and restarted via `./run-stage.sh --nexus-only` (or `-n`).
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
- ASPNETCORE_ENVIRONMENT=Staging
|
- ASPNETCORE_ENVIRONMENT=Staging
|
||||||
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
||||||
|
- Qdrant__ApiKey=${QDRANT_API_KEY:-}
|
||||||
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
||||||
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
||||||
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
||||||
|
|||||||
+55
-4
@@ -4,11 +4,45 @@
|
|||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
NEXUS_ONLY=false
|
||||||
|
STOP=false
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
--nexus-only|-n)
|
||||||
|
NEXUS_ONLY=true
|
||||||
|
;;
|
||||||
|
--stop|-s)
|
||||||
|
STOP=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
ENV_FILE=".env.stage"
|
ENV_FILE=".env.stage"
|
||||||
TEMPLATE_FILE=".env.stage.template"
|
TEMPLATE_FILE=".env.stage.template"
|
||||||
COMPOSE_FILE="docker-compose.stage.yml"
|
COMPOSE_FILE="docker-compose.stage.yml"
|
||||||
|
|
||||||
|
if [ "$STOP" = true ]; then
|
||||||
|
echo "🛑 Stopping staging environment..."
|
||||||
|
if [ ! -f "$ENV_FILE" ] && [ -f "$TEMPLATE_FILE" ]; then
|
||||||
|
cp "$TEMPLATE_FILE" "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
if [ "$NEXUS_ONLY" = true ]; then
|
||||||
|
echo "🧹 Stopping and removing only the web (nexus) container..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
|
||||||
|
else
|
||||||
|
echo "🧹 Stopping all containers..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
|
||||||
|
docker compose down --remove-orphans 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "✅ Staging environment stopped."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo "🏁 Starting staging environment orchestration..."
|
echo "🏁 Starting staging environment orchestration..."
|
||||||
|
if [ "$NEXUS_ONLY" = true ]; then
|
||||||
|
echo "ℹ️ Mode: --nexus-only (only the web/nexus application container will be modified)"
|
||||||
|
fi
|
||||||
|
|
||||||
# 1. Create .env.stage if it doesn't exist
|
# 1. Create .env.stage if it doesn't exist
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
@@ -37,6 +71,12 @@ if grep -q "CHANGE_ME_TO_SECURE_ADMIN_PASSWORD" "$ENV_FILE"; then
|
|||||||
sed -i "s/NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD/NEXUS_ADMIN_PASSWORD=$ADMIN_PASS/g" "$ENV_FILE"
|
sed -i "s/NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD/NEXUS_ADMIN_PASSWORD=$ADMIN_PASS/g" "$ENV_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if grep -q "^QDRANT_API_KEY=$" "$ENV_FILE" || grep -q "^QDRANT_API_KEY=[[:space:]]*$" "$ENV_FILE"; then
|
||||||
|
echo "🔐 Generating secure random Qdrant API key in $ENV_FILE..."
|
||||||
|
QD_KEY=$(openssl rand -hex 16)
|
||||||
|
sed -i "s/^QDRANT_API_KEY=.*/QDRANT_API_KEY=$QD_KEY/g" "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
# Load staging variables for local execution context (needed for ports/migrations)
|
# Load staging variables for local execution context (needed for ports/migrations)
|
||||||
# Clean up carriage returns just in case
|
# Clean up carriage returns just in case
|
||||||
POSTGRES_USER=$(grep "^POSTGRES_USER=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
POSTGRES_USER=$(grep "^POSTGRES_USER=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
||||||
@@ -52,13 +92,24 @@ POSTGRES_PORT=${POSTGRES_PORT:-5438}
|
|||||||
WEB_PORT=${WEB_PORT:-5080}
|
WEB_PORT=${WEB_PORT:-5080}
|
||||||
|
|
||||||
# 3. Stop any conflicting Docker Compose environments
|
# 3. Stop any conflicting Docker Compose environments
|
||||||
|
if [ "$NEXUS_ONLY" = true ]; then
|
||||||
|
echo "🧹 Stopping and removing only the web (nexus) container..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
|
||||||
|
else
|
||||||
echo "🧹 Stopping existing containers..."
|
echo "🧹 Stopping existing containers..."
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
|
||||||
docker compose down --remove-orphans 2>/dev/null || true
|
docker compose down --remove-orphans 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
# 4. Build and start containers
|
# 4. Build and start containers
|
||||||
|
if [ "$NEXUS_ONLY" = true ]; then
|
||||||
|
echo "🚀 Building and restarting only the web (nexus) container..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build web
|
||||||
|
else
|
||||||
echo "🚀 Building and starting staging containers..."
|
echo "🚀 Building and starting staging containers..."
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build
|
||||||
|
fi
|
||||||
|
|
||||||
# 5. Wait for Database to be healthy
|
# 5. Wait for Database to be healthy
|
||||||
echo "⏳ Waiting for database (nexus-db-stage) to become healthy..."
|
echo "⏳ Waiting for database (nexus-db-stage) to become healthy..."
|
||||||
@@ -81,14 +132,14 @@ export ConnectionStrings__PostgresConnection="Host=127.0.0.1;Port=$POSTGRES_PORT
|
|||||||
dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web --no-build
|
dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web --no-build
|
||||||
|
|
||||||
# 7. Wait for Web Application to respond
|
# 7. Wait for Web Application to respond
|
||||||
echo "⏳ Waiting for Web Application to start on http://localhost:$WEB_PORT..."
|
echo "⏳ Waiting for Web Application to start on http://localhost:$WEB_PORT/health..."
|
||||||
MAX_WEB_ATTEMPTS=20
|
MAX_WEB_ATTEMPTS=30
|
||||||
web_attempt=0
|
web_attempt=0
|
||||||
until curl -s -f "http://localhost:$WEB_PORT" >/dev/null; do
|
until curl -s -f "http://localhost:$WEB_PORT/health" >/dev/null; do
|
||||||
sleep 2
|
sleep 2
|
||||||
web_attempt=$((web_attempt + 1))
|
web_attempt=$((web_attempt + 1))
|
||||||
if [ $web_attempt -ge $MAX_WEB_ATTEMPTS ]; then
|
if [ $web_attempt -ge $MAX_WEB_ATTEMPTS ]; then
|
||||||
echo "⚠️ Warning: Web app is not responding yet on http://localhost:$WEB_PORT, but let's check logs..."
|
echo "⚠️ Warning: Web app is not responding yet on http://localhost:$WEB_PORT/health, but let's check logs..."
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs web
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs web
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ public class PublishBookVersionCommandHandler : ICommandHandler<PublishBookVersi
|
|||||||
|
|
||||||
if (book == null)
|
if (book == null)
|
||||||
{
|
{
|
||||||
throw new BookNotFoundException(request.BookId);
|
return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found."));
|
||||||
}
|
}
|
||||||
|
|
||||||
var oldDraftRevision = book.CurrentDraftRevision;
|
var oldDraftRevision = book.CurrentDraftRevision;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public class GetBookRevisionsQueryHandler : IQueryHandler<GetBookRevisionsQuery,
|
|||||||
|
|
||||||
if (!bookExists)
|
if (!bookExists)
|
||||||
{
|
{
|
||||||
throw new BookNotFoundException(request.BookId);
|
return FluentResults.Result.Fail<List<CreatorBookRevisionDto>>(new FluentResults.Error($"Book with ID '{request.BookId}' was not found."));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all revisions sorted chronologically
|
// Fetch all revisions sorted chronologically
|
||||||
|
|||||||
@@ -55,7 +55,15 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// Qdrant Client registration
|
// Qdrant Client registration
|
||||||
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
||||||
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl)));
|
var qdrantApiKey = configuration["Qdrant:ApiKey"];
|
||||||
|
services.AddSingleton<QdrantClient>(sp =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(qdrantApiKey))
|
||||||
|
{
|
||||||
|
return new QdrantClient(new Uri(qdrantUrl), apiKey: qdrantApiKey);
|
||||||
|
}
|
||||||
|
return new QdrantClient(new Uri(qdrantUrl));
|
||||||
|
});
|
||||||
|
|
||||||
// Neo4j Driver registration (supports optional authentication)
|
// Neo4j Driver registration (supports optional authentication)
|
||||||
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
private IJSObjectReference? _module;
|
private IJSObjectReference? _module;
|
||||||
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
|
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
|
||||||
|
private string? _lastInitializedEditorId;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
private enum SaveStatus
|
private enum SaveStatus
|
||||||
{
|
{
|
||||||
@@ -136,9 +138,11 @@
|
|||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (firstRender || _reinitializeEditor)
|
var shouldInit = (firstRender || _reinitializeEditor) && (EditorId != _lastInitializedEditorId);
|
||||||
|
if (shouldInit)
|
||||||
{
|
{
|
||||||
_reinitializeEditor = false;
|
_reinitializeEditor = false;
|
||||||
|
_lastInitializedEditorId = EditorId; // Set immediately before any async yield to prevent concurrent triggers
|
||||||
|
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
@@ -153,7 +157,7 @@
|
|||||||
{
|
{
|
||||||
_module = await JS.InvokeAsync<IJSObjectReference>(
|
_module = await JS.InvokeAsync<IJSObjectReference>(
|
||||||
"import",
|
"import",
|
||||||
"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js"
|
$"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown);
|
await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown);
|
||||||
@@ -178,7 +182,7 @@
|
|||||||
{
|
{
|
||||||
_module = await JS.InvokeAsync<IJSObjectReference>(
|
_module = await JS.InvokeAsync<IJSObjectReference>(
|
||||||
"import",
|
"import",
|
||||||
"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js"
|
$"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +351,7 @@
|
|||||||
|
|
||||||
// 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)
|
||||||
@@ -355,6 +360,7 @@
|
|||||||
_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)
|
||||||
@@ -373,13 +379,6 @@
|
|||||||
// 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);
|
||||||
@@ -398,7 +397,7 @@
|
|||||||
|
|
||||||
private async Task TriggerAutosaveAsync(string markdown, CancellationToken token)
|
private async Task TriggerAutosaveAsync(string markdown, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (token.IsCancellationRequested) return;
|
if (token.IsCancellationRequested || _disposed) return;
|
||||||
|
|
||||||
_status = SaveStatus.Saving;
|
_status = SaveStatus.Saving;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
@@ -413,6 +412,8 @@
|
|||||||
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
|
||||||
@@ -428,10 +429,12 @@
|
|||||||
}
|
}
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +477,7 @@
|
|||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
_disposed = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_cts.Cancel();
|
_cts.Cancel();
|
||||||
@@ -509,9 +513,29 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Always try to destroy via global window registration first to handle null _module
|
||||||
|
await JS.InvokeVoidAsync("milkdownWrapper.destroyEditor", EditorId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fallback to module if global is not set
|
||||||
if (_module is not null)
|
if (_module is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
await _module.InvokeVoidAsync("destroyEditor", EditorId);
|
await _module.InvokeVoidAsync("destroyEditor", EditorId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fail silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_module is not null)
|
||||||
|
{
|
||||||
await _module.DisposeAsync();
|
await _module.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -568,3 +604,43 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -183,10 +183,11 @@
|
|||||||
InvokeAsync(StateHasChanged);
|
InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnAfterRender(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
|
await ThemeService.InitializeAsync();
|
||||||
_isFullyLoaded = true;
|
_isFullyLoaded = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,6 +354,8 @@
|
|||||||
/* --- Desktop Sidebar: warm paper shadow --- */
|
/* --- Desktop Sidebar: warm paper shadow --- */
|
||||||
.theme-light ::deep .hub-sidebar {
|
.theme-light ::deep .hub-sidebar {
|
||||||
box-shadow: 4px 0 20px rgba(139, 130, 115, 0.08);
|
box-shadow: 4px 0 20px rgba(139, 130, 115, 0.08);
|
||||||
|
background: var(--bg-surface) !important;
|
||||||
|
border-right: 1px solid var(--border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Logo icon: remove neon glow --- */
|
/* --- Logo icon: remove neon glow --- */
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
@page "/creator/edit/{BookId:guid}"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@using System.Net.Http.Json
|
|
||||||
@using Microsoft.Extensions.Logging
|
|
||||||
@using NexusReader.Application.DTOs.Creator
|
|
||||||
@inject HttpClient Http
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
@inject ILogger<Creator> Logger
|
|
||||||
|
|
||||||
<PageTitle>Workspace Autora | Nexus Reader</PageTitle>
|
|
||||||
|
|
||||||
<div class="workspace-container">
|
|
||||||
<!-- Left Sidebar for Chapter Selection -->
|
|
||||||
<aside class="workspace-sidebar glass-panel">
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<button type="button" class="back-dashboard-btn" @onclick="NavigateToDashboard">
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<polyline points="15 18 9 12 15 6"></polyline>
|
|
||||||
</svg>
|
|
||||||
<span>Dashboard</span>
|
|
||||||
</button>
|
|
||||||
<h3 class="sidebar-title">Rozdziały</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="chapters-nav">
|
|
||||||
@if (_chaptersLoading)
|
|
||||||
{
|
|
||||||
<div class="sidebar-loading">
|
|
||||||
<div class="spinner-glow small"></div>
|
|
||||||
<span>Ładowanie spisu...</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (_chapters == null || !_chapters.Any())
|
|
||||||
{
|
|
||||||
<div class="sidebar-empty">Brak rozdziałów w tej wersji.</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<ul class="chapters-list">
|
|
||||||
@foreach (var ch in _chapters)
|
|
||||||
{
|
|
||||||
<li class="chapter-item @(ch.Id == _activeChapterId ? "active" : "")" @onclick="() => LoadChapterContentAsync(ch.Id)">
|
|
||||||
<span class="chapter-order">@ch.SortOrder.</span>
|
|
||||||
<span class="chapter-name" title="@ch.Title">@ch.Title</span>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Right Workspace Area -->
|
|
||||||
<main class="workspace-content">
|
|
||||||
@if (_contentLoading)
|
|
||||||
{
|
|
||||||
<div class="editor-loading-placeholder glass-panel">
|
|
||||||
<div class="spinner-glow"></div>
|
|
||||||
<h3 class="loading-title">Wczytywanie treści rozdziału...</h3>
|
|
||||||
<p>Przygotowywanie edytora Zen Mode i sprawdzanie kopii zapasowych w LocalStorage...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (_activeChapterId == Guid.Empty)
|
|
||||||
{
|
|
||||||
<div class="workspace-empty glass-panel">
|
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4z"></path>
|
|
||||||
</svg>
|
|
||||||
<h3>Wybierz rozdział z listy</h3>
|
|
||||||
<p>Kliknij na dowolny tytuł w panelu bocznym, aby rozpocząć pisanie lub edycję.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="editor-workspace-card glass-panel" spellcheck="false">
|
|
||||||
<div class="editor-header-meta">
|
|
||||||
<h2 class="active-chapter-title">@_activeChapterTitle</h2>
|
|
||||||
<span class="chapter-id-badge">ID: @_activeChapterId.ToString().Substring(0, 8)...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="editor-growing-area">
|
|
||||||
<MarkdownEditor @ref="_editorRef"
|
|
||||||
InitialMarkdown="@_chapterMarkdown"
|
|
||||||
ChapterId="@_activeChapterId"
|
|
||||||
ServerTimestamp="@_serverTimestamp"
|
|
||||||
OnSave="HandleSave"
|
|
||||||
ShowFetchButton="true"
|
|
||||||
Height="100%" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter]
|
|
||||||
public Guid? BookId { get; set; }
|
|
||||||
|
|
||||||
private MarkdownEditor? _editorRef;
|
|
||||||
private bool _chaptersLoading = true;
|
|
||||||
private bool _contentLoading = false;
|
|
||||||
|
|
||||||
private List<ChapterItemDto> _chapters = new();
|
|
||||||
private Guid _activeChapterId = Guid.Empty;
|
|
||||||
private string _activeChapterTitle = string.Empty;
|
|
||||||
private string _chapterMarkdown = string.Empty;
|
|
||||||
private DateTime _serverTimestamp = DateTime.UtcNow;
|
|
||||||
|
|
||||||
public class ChapterItemDto
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
public int SortOrder { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ChapterDetailsDto
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
public string MarkdownContent { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
|
||||||
{
|
|
||||||
await base.OnParametersSetAsync();
|
|
||||||
|
|
||||||
if (BookId.HasValue && BookId.Value != Guid.Empty)
|
|
||||||
{
|
|
||||||
await LoadBookChaptersAsync();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_chaptersLoading = false;
|
|
||||||
_chapters.Clear();
|
|
||||||
_activeChapterId = Guid.Empty;
|
|
||||||
_chapterMarkdown = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadBookChaptersAsync()
|
|
||||||
{
|
|
||||||
_chaptersLoading = true;
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_chapters = await Http.GetFromJsonAsync<List<ChapterItemDto>>($"api/creator/books/{BookId}/chapters") ?? new();
|
|
||||||
|
|
||||||
// Extract the query parameter chapterId if available
|
|
||||||
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
|
||||||
Guid targetChapterId = Guid.Empty;
|
|
||||||
|
|
||||||
if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("chapterId", out var chapterValue))
|
|
||||||
{
|
|
||||||
Guid.TryParse(chapterValue, out targetChapterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetChapterId != Guid.Empty && _chapters.Any(c => c.Id == targetChapterId))
|
|
||||||
{
|
|
||||||
await LoadChapterContentAsync(targetChapterId);
|
|
||||||
}
|
|
||||||
else if (_chapters.Any())
|
|
||||||
{
|
|
||||||
await LoadChapterContentAsync(_chapters.First().Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Failed to load book chapters.");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_chaptersLoading = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadChapterContentAsync(Guid chapterId)
|
|
||||||
{
|
|
||||||
if (chapterId == Guid.Empty) return;
|
|
||||||
|
|
||||||
_contentLoading = true;
|
|
||||||
_activeChapterId = chapterId;
|
|
||||||
_activeChapterTitle = _chapters.FirstOrDefault(c => c.Id == chapterId)?.Title ?? "Rozdział";
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var details = await Http.GetFromJsonAsync<ChapterDetailsDto>($"api/chapters/{chapterId}");
|
|
||||||
if (details != null)
|
|
||||||
{
|
|
||||||
_chapterMarkdown = details.MarkdownContent;
|
|
||||||
_serverTimestamp = DateTime.UtcNow; // Used to check database sync freshness
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Failed to load chapter content.");
|
|
||||||
_chapterMarkdown = string.Empty;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_contentLoading = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleSave(string markdown)
|
|
||||||
{
|
|
||||||
_chapterMarkdown = markdown;
|
|
||||||
Logger.LogInformation("Saved markdown content length: {Length}", markdown.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void NavigateToDashboard()
|
|
||||||
{
|
|
||||||
NavigationManager.NavigateTo("/creator");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
.workspace-container {
|
|
||||||
display: flex;
|
|
||||||
min-height: calc(100vh - 64px); /* assuming top navbar is 64px */
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg-base);
|
|
||||||
animation: fade-in 0.4s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Left Sidebar --- */
|
|
||||||
.workspace-sidebar {
|
|
||||||
width: 280px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1.5rem 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header {
|
|
||||||
padding: 0 1.5rem 1.5rem;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-dashboard-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
transition: color 0.2s;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-dashboard-btn:hover {
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-title {
|
|
||||||
font-family: var(--nexus-font-serif, serif);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-main);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapters-nav {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-loading, .sidebar-empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapters-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-item {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-item:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-item.active {
|
|
||||||
background: rgba(16, 185, 129, 0.03);
|
|
||||||
border-left-color: var(--accent);
|
|
||||||
color: var(--text-main);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-order {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-name {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Right Content Workspace --- */
|
|
||||||
.workspace-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 2.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow-y: auto;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-empty, .editor-loading-placeholder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: 4rem 2rem;
|
|
||||||
gap: 1.5rem;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-empty svg {
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-empty h3, .loading-title {
|
|
||||||
font-family: var(--nexus-font-serif, serif);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-main);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-empty p {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
max-width: 400px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-workspace-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding: 2rem;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-header-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-chapter-title {
|
|
||||||
font-family: var(--nexus-font-serif, serif);
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-main);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-id-badge {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-growing-area {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glassmorphism Panel styles */
|
|
||||||
.glass-panel {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.03);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-glow {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border: 3px solid rgba(16, 185, 129, 0.1);
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin-glow 1s linear infinite;
|
|
||||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin-glow {
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Mobile View Adjustments --- */
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
.workspace-sidebar {
|
|
||||||
width: 220px;
|
|
||||||
}
|
|
||||||
.workspace-content {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.workspace-container {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.workspace-sidebar {
|
|
||||||
width: 100%;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
.chapters-list {
|
|
||||||
flex-direction: row;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
.chapter-item {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-left: none;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.chapter-item.active {
|
|
||||||
border-bottom-color: var(--accent);
|
|
||||||
}
|
|
||||||
.sidebar-header {
|
|
||||||
padding: 0 1rem 0.5rem;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.workspace-content {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
.active-chapter-title {
|
|
||||||
font-size: 1.35rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -372,7 +372,7 @@
|
|||||||
{
|
{
|
||||||
if (book.FirstChapterId.HasValue)
|
if (book.FirstChapterId.HasValue)
|
||||||
{
|
{
|
||||||
NavigationManager.NavigateTo($"/creator/edit/{book.Id}?chapterId={book.FirstChapterId.Value}");
|
NavigationManager.NavigateTo($"/creator/edit/{book.Id}/{book.FirstChapterId.Value}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
@page "/creator/edit/{BookId}"
|
||||||
|
@page "/creator/edit/{BookId}/{ChapterId}"
|
||||||
|
@layout MainHubLayout
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using NexusReader.UI.Shared.Components
|
||||||
|
|
||||||
|
@if (_loadingChapters)
|
||||||
|
{
|
||||||
|
<div class="hub-loading" style="height: calc(100vh - 4rem); display: flex; flex-direction: column; justify-content: center; align-items: center; background-color: var(--bg-base);">
|
||||||
|
<div class="nexus-loader"></div>
|
||||||
|
<p style="margin-top: 1rem; color: var(--text-muted); font-family: var(--nexus-font-sans);">Ładowanie struktury książki...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="creator-edit-fullscreen-wrapper">
|
||||||
|
|
||||||
|
<div class="chapters-sidebar">
|
||||||
|
<div class="sidebar-meta-header">
|
||||||
|
<h2>Rozdziały</h2>
|
||||||
|
</div>
|
||||||
|
<div class="chapters-list-wrapper">
|
||||||
|
@foreach (var ch in _chapters)
|
||||||
|
{
|
||||||
|
var isActive = ch.Id == _activeChapterId;
|
||||||
|
<a class="chapter-item @(isActive ? "active" : "")" href="/creator/edit/@BookId/@ch.Id">
|
||||||
|
@if (isActive)
|
||||||
|
{
|
||||||
|
<div class="active-indicator"></div>
|
||||||
|
<i class="fa-solid fa-book-open chapter-icon"></i>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="fa-solid fa-file-lines chapter-icon"></i>
|
||||||
|
}
|
||||||
|
<span class="chapter-title-text">@ch.Title</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-workspace-area">
|
||||||
|
|
||||||
|
<div class="editor-header-row">
|
||||||
|
<div class="title-zone">
|
||||||
|
<h1 class="chapter-title">@_activeChapterTitle</h1>
|
||||||
|
</div>
|
||||||
|
<div class="telemetry-zone">
|
||||||
|
<span class="chapter-id-badge">ID: @_activeChapterId</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-canvas-card">
|
||||||
|
@if (_loadingChapter)
|
||||||
|
{
|
||||||
|
<div class="hub-loading" style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center;">
|
||||||
|
<div class="nexus-loader"></div>
|
||||||
|
<p style="margin-top: 1rem; color: var(--text-muted); font-family: var(--nexus-font-sans);">Wczytywanie treści rozdziału...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_isChapterLoaded)
|
||||||
|
{
|
||||||
|
<div class="milkdown-premium-container" spellcheck="false">
|
||||||
|
<MarkdownEditor @key="_activeChapterId"
|
||||||
|
ChapterId="_activeChapterId"
|
||||||
|
InitialMarkdown="@_initialMarkdown"
|
||||||
|
ShowFetchButton="false" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; color: var(--text-muted); font-family: var(--nexus-font-sans);">
|
||||||
|
<p>Wybierz lub utwórz rozdział, aby rozpocząć edycję.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] private HttpClient Http { get; set; } = default!;
|
||||||
|
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
||||||
|
|
||||||
|
[Parameter] public string BookId { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string ChapterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private List<ChapterListItem> _chapters = new();
|
||||||
|
private Guid _parsedBookId = Guid.Empty;
|
||||||
|
private Guid _activeChapterId = Guid.Empty;
|
||||||
|
private string _activeChapterTitle = string.Empty;
|
||||||
|
private string _initialMarkdown = string.Empty;
|
||||||
|
private bool _loadingChapters = true;
|
||||||
|
private bool _loadingChapter = false;
|
||||||
|
private bool _isChapterLoaded = false;
|
||||||
|
|
||||||
|
private class ChapterListItem
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ChapterDetail
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string MarkdownContent { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
await base.OnParametersSetAsync();
|
||||||
|
|
||||||
|
if (!Guid.TryParse(BookId, out var parsedBookId))
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo("/creator");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_parsedBookId = parsedBookId;
|
||||||
|
|
||||||
|
// Fetch chapters list if empty or if book ID has changed
|
||||||
|
if (_chapters.Count == 0)
|
||||||
|
{
|
||||||
|
_loadingChapters = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var chapters = await Http.GetFromJsonAsync<List<ChapterListItem>>($"/api/creator/books/{_parsedBookId}/chapters");
|
||||||
|
_chapters = chapters ?? new();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[CreatorEdit] Error fetching chapters list: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loadingChapters = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If ChapterId is empty/null, select the first chapter from list and navigate
|
||||||
|
if (string.IsNullOrEmpty(ChapterId))
|
||||||
|
{
|
||||||
|
if (_chapters.Any())
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo($"/creator/edit/{BookId}/{_chapters.First().Id}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Guid.TryParse(ChapterId, out var parsedChapterId))
|
||||||
|
{
|
||||||
|
// If active chapter changed, fetch its details
|
||||||
|
if (parsedChapterId != _activeChapterId)
|
||||||
|
{
|
||||||
|
_activeChapterId = parsedChapterId;
|
||||||
|
var ch = _chapters.FirstOrDefault(c => c.Id == _activeChapterId);
|
||||||
|
_activeChapterTitle = ch?.Title ?? "Rozdział";
|
||||||
|
|
||||||
|
_loadingChapter = true;
|
||||||
|
_isChapterLoaded = false;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var detail = await Http.GetFromJsonAsync<ChapterDetail>($"/api/chapters/{_activeChapterId}");
|
||||||
|
if (detail != null)
|
||||||
|
{
|
||||||
|
_initialMarkdown = detail.MarkdownContent;
|
||||||
|
_isChapterLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[CreatorEdit] Error fetching chapter content: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loadingChapter = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
NEXUSREADER CREATOR EDIT MODE - HIGH-FIDELITY SAAS PREMIUM DESIGN OVERRIDE
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* 1. ARCHITECTURAL BOUNDARY CONTROL */
|
||||||
|
.creator-edit-fullscreen-wrapper {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
height: calc(100vh - 4rem) !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
background-color: #121214;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dynamic theme bridge mapping for Warm Paper mode */
|
||||||
|
.theme-light .creator-edit-fullscreen-wrapper {
|
||||||
|
background-color: #f4f1ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. UNIFIED SIDEBAR DESIGN (Eliminating layout color fragmentation) */
|
||||||
|
.chapters-sidebar {
|
||||||
|
width: 300px !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: #16161a !important;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.04) !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 2.5rem 1.5rem !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chapters-sidebar {
|
||||||
|
background-color: #eae6db !important; /* Rich warm tone that remains fully cohesive with warm paper base */
|
||||||
|
border-right: 1px solid #dcd7cc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-meta-header h2 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: #a1a1aa;
|
||||||
|
margin: 0 0 1.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .sidebar-meta-header h2 {
|
||||||
|
color: #78716c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapters-list-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Premium Navigation Links */
|
||||||
|
.chapter-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #a1a1aa;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chapter-item {
|
||||||
|
color: #78716c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item i.chapter-icon {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #71717a;
|
||||||
|
transition: color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active Indicator Node Alignment */
|
||||||
|
.chapter-item.active {
|
||||||
|
background-color: rgba(0, 255, 153, 0.05) !important;
|
||||||
|
color: #00ff99 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chapter-item.active {
|
||||||
|
background-color: rgba(16, 185, 129, 0.06) !important;
|
||||||
|
color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item.active i.chapter-icon {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item:hover:not(.active) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chapter-item:hover:not(.active) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
color: #2d2a26;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. WORKSPACE METRICS (Zen presentation spacing) */
|
||||||
|
.editor-workspace-area {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 3rem 4rem 2.5rem 4rem !important; /* Generous padding context for premium scale */
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-workspace-area h1.chapter-title {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .editor-workspace-area h1.chapter-title {
|
||||||
|
color: #2d2a26;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-id-badge {
|
||||||
|
font-family: 'Azeret Mono', monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #71717a;
|
||||||
|
background: #1a1a1e;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .chapter-id-badge {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #78716c;
|
||||||
|
border: 1px solid #dcd7cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. ELEVATED EDITOR CANVAS CARD (Introducing layered shadow mechanics) */
|
||||||
|
.editor-canvas-card {
|
||||||
|
background-color: #1a1a1e !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04) !important;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 3rem !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Soft diffuse structural shadows mimicking actual surface elevation */
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .editor-canvas-card {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
border: 1px solid #dcd7cc !important;
|
||||||
|
box-shadow: 0 20px 50px rgba(45, 42, 38, 0.04), 0 4px 12px rgba(45, 42, 38, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DEEP MOUNTING COMPONENT INTEROP */
|
||||||
|
.milkdown-premium-container ::deep .milkdown {
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .ProseMirror {
|
||||||
|
color: #e4e1d9 !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
font-size: 1.15rem !important;
|
||||||
|
line-height: 1.8 !important;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
padding-right: 24px !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .ProseMirror {
|
||||||
|
color: #2d2a26 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Precise matching text selection token */
|
||||||
|
.milkdown-premium-container ::deep .ProseMirror ::selection {
|
||||||
|
background-color: rgba(0, 255, 153, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .ProseMirror ::selection {
|
||||||
|
background-color: rgba(16, 185, 129, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Core webkit custom scrollbar mapping */
|
||||||
|
.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-thumb {
|
||||||
|
background: #dcd7cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 5. SEAMLESS INTEGRATED ACTIONS FOOTER BAR (OVERWRITING FOR MARKDOWNEDITOR COMPONENT INTEGRATION) */
|
||||||
|
.milkdown-premium-container ::deep .markdown-editor-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .milkdown-editor-wrapper {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .milkdown {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .editor-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 2rem !important;
|
||||||
|
padding: 1.5rem 0 0 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .editor-footer {
|
||||||
|
border-top: 1px solid #dcd7cc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Telemetry cloud synchronization line mapping */
|
||||||
|
.milkdown-premium-container ::deep .status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: 'Azeret Mono', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #71717a;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .status-indicator {
|
||||||
|
color: #78716c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .status-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .status-dot.saved {
|
||||||
|
background-color: #00ff99 !important;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 255, 153, 0.8) !important;
|
||||||
|
color: #00ff99 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .status-dot.saved {
|
||||||
|
background-color: #10b981 !important;
|
||||||
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.6) !important;
|
||||||
|
color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .status-dot.saving {
|
||||||
|
background-color: #F59E0B !important;
|
||||||
|
box-shadow: 0 0 10px rgba(245, 158, 11, 0.8) !important;
|
||||||
|
color: #F59E0B !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .status-dot.offline {
|
||||||
|
background-color: #EF4444 !important;
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.8) !important;
|
||||||
|
color: #EF4444 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Premium Tactile Operational Button Trigger */
|
||||||
|
.milkdown-premium-container ::deep .nexus-btn {
|
||||||
|
background-color: #00ff99 !important;
|
||||||
|
color: #121214 !important;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: -0.1px;
|
||||||
|
padding: 11px 24px !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 255, 153, 0.15);
|
||||||
|
height: auto !important;
|
||||||
|
min-height: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .nexus-btn {
|
||||||
|
background-color: #10b981 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-premium-container ::deep .nexus-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 255, 153, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .milkdown-premium-container ::deep .nexus-btn:hover {
|
||||||
|
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
// Map to keep track of active Crepe editor instances by elementId (container ID)
|
// Initialize global stores on window to share state across dynamically imported module instances (preventing cache-buster isolation)
|
||||||
const editorCache = new Map();
|
if (typeof window !== 'undefined') {
|
||||||
|
if (!window.editorCache) window.editorCache = new Map();
|
||||||
|
if (!window.editorStates) window.editorStates = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorCache = typeof window !== 'undefined' ? window.editorCache : new Map();
|
||||||
|
const editorStates = typeof window !== 'undefined' ? window.editorStates : new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronously injects a stylesheet link tag into the document head
|
* Asynchronously injects a stylesheet link tag into the document head
|
||||||
@@ -23,19 +29,64 @@ async function ensureStylesheet(href) {
|
|||||||
* Initializes a Milkdown Crepe editor on the specified element.
|
* Initializes a Milkdown Crepe editor on the specified element.
|
||||||
*/
|
*/
|
||||||
export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
|
export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
|
||||||
|
// Check if already destroyed or initializing
|
||||||
|
if (editorStates.get(elementId) === 'destroyed') {
|
||||||
|
console.warn(`[Milkdown] initEditor called on already destroyed element: ${elementId}. Aborting.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editorStates.get(elementId) === 'initializing' || editorStates.get(elementId) === 'ready') {
|
||||||
|
console.warn(`[Milkdown] Editor is already initializing or ready for element: ${elementId}. Ignoring.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorStates.set(elementId, 'initializing');
|
||||||
|
|
||||||
|
// Guard 1: Destroy previous cached editor instance with the same ID if it exists
|
||||||
|
if (editorCache.has(elementId)) {
|
||||||
|
console.warn(`[Milkdown] Editor instance already exists in cache for: ${elementId}. Destroying first.`);
|
||||||
|
await destroyEditor(elementId);
|
||||||
|
}
|
||||||
|
|
||||||
const container = document.getElementById(elementId);
|
const container = document.getElementById(elementId);
|
||||||
if (!container) {
|
if (!container) {
|
||||||
console.error(`[Milkdown] Container with ID "${elementId}" not found.`);
|
console.error(`[Milkdown] Container with ID "${elementId}" not found.`);
|
||||||
|
editorStates.delete(elementId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guard 2: Clear container children to prevent double-initialization of crepe editor DOM
|
||||||
|
if (container.children.length > 0) {
|
||||||
|
console.warn(`[Milkdown] Container "${elementId}" is not empty. Clearing children before initialization.`);
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard 3: Search the parent workspace card to purge any other leftover editor components
|
||||||
|
const parentCard = container.closest('.milkdown-premium-container') || container.parentElement;
|
||||||
|
if (parentCard) {
|
||||||
|
const existingEditors = parentCard.querySelectorAll('.milkdown, .crepe');
|
||||||
|
if (existingEditors.length > 0) {
|
||||||
|
console.warn(`[Milkdown] Found ${existingEditors.length} leftover editor DOM elements in the workspace card. Purging them.`);
|
||||||
|
existingEditors.forEach(el => el.remove());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Condition 2: Prevent FOUC by loading stylesheets before instantiating the editor
|
// Condition 2: Prevent FOUC by loading stylesheets before instantiating the editor
|
||||||
await ensureStylesheet('/_content/NexusReader.UI.Shared/css/vendor/milkdown-crepe.css');
|
await ensureStylesheet('/_content/NexusReader.UI.Shared/css/vendor/milkdown-crepe.css');
|
||||||
|
|
||||||
|
if (editorStates.get(elementId) === 'destroyed') {
|
||||||
|
console.warn(`[Milkdown] Element ${elementId} destroyed during stylesheet loading. Aborting.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Dynamically import the local JS bundle
|
// Dynamically import the local JS bundle
|
||||||
await import('/_content/NexusReader.UI.Shared/js/vendor/milkdown-crepe.js');
|
await import('/_content/NexusReader.UI.Shared/js/vendor/milkdown-crepe.js');
|
||||||
|
|
||||||
|
if (editorStates.get(elementId) === 'destroyed') {
|
||||||
|
console.warn(`[Milkdown] Element ${elementId} destroyed during crepe bundle loading. Aborting.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get Crepe constructor from the global window.milkdownCrepe namespace
|
// Get Crepe constructor from the global window.milkdownCrepe namespace
|
||||||
const Crepe = window.milkdownCrepe?.Crepe;
|
const Crepe = window.milkdownCrepe?.Crepe;
|
||||||
if (!Crepe) {
|
if (!Crepe) {
|
||||||
@@ -100,6 +151,7 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
|
|||||||
clearTimeout(debounceTimeout);
|
clearTimeout(debounceTimeout);
|
||||||
}
|
}
|
||||||
debounceTimeout = setTimeout(() => {
|
debounceTimeout = setTimeout(() => {
|
||||||
|
if (editorStates.get(elementId) === 'destroyed') return;
|
||||||
dotNetHelper.invokeMethodAsync('OnEditorContentChanged', markdown)
|
dotNetHelper.invokeMethodAsync('OnEditorContentChanged', markdown)
|
||||||
.catch(err => console.error("[Milkdown] Failed to notify editor content changed:", err));
|
.catch(err => console.error("[Milkdown] Failed to notify editor content changed:", err));
|
||||||
}, 300);
|
}, 300);
|
||||||
@@ -112,8 +164,17 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
|
|||||||
// Create the editor view asynchronously
|
// Create the editor view asynchronously
|
||||||
await crepe.create();
|
await crepe.create();
|
||||||
|
|
||||||
|
if (editorStates.get(elementId) === 'destroyed') {
|
||||||
|
console.warn(`[Milkdown] Element ${elementId} destroyed during crepe.create(). Cleaning up.`);
|
||||||
|
await crepe.destroy();
|
||||||
|
editorCache.delete(elementId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorStates.set(elementId, 'ready');
|
||||||
console.log(`[Milkdown] Editor successfully initialized on element: ${elementId}`);
|
console.log(`[Milkdown] Editor successfully initialized on element: ${elementId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
editorStates.delete(elementId);
|
||||||
console.error(`[Milkdown] Failed to initialize editor on "${elementId}":`, error);
|
console.error(`[Milkdown] Failed to initialize editor on "${elementId}":`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +195,8 @@ export function getMarkdownContent(elementId) {
|
|||||||
* Safely disposes of the editor instance to prevent memory leaks in WASM.
|
* Safely disposes of the editor instance to prevent memory leaks in WASM.
|
||||||
*/
|
*/
|
||||||
export async function destroyEditor(elementId) {
|
export async function destroyEditor(elementId) {
|
||||||
|
editorStates.set(elementId, 'destroyed');
|
||||||
|
|
||||||
const crepe = editorCache.get(elementId);
|
const crepe = editorCache.get(elementId);
|
||||||
if (crepe) {
|
if (crepe) {
|
||||||
try {
|
try {
|
||||||
@@ -144,6 +207,12 @@ export async function destroyEditor(elementId) {
|
|||||||
}
|
}
|
||||||
editorCache.delete(elementId);
|
editorCache.delete(elementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explicitly clean up container DOM children
|
||||||
|
const container = document.getElementById(elementId);
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,3 +232,13 @@ export function getBackupKeys() {
|
|||||||
}
|
}
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach to window for global access (especially from DisposeAsync when module reference is null)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.milkdownWrapper = {
|
||||||
|
initEditor,
|
||||||
|
getMarkdownContent,
|
||||||
|
destroyEditor,
|
||||||
|
getBackupKeys
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -91,6 +92,10 @@ builder.Services.AddCascadingAuthenticationState();
|
|||||||
|
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddCheck<NexusReader.Web.Services.DatabaseHealthCheck>("Database")
|
||||||
|
.AddCheck<NexusReader.Web.Services.QdrantHealthCheck>("Qdrant")
|
||||||
|
.AddCheck<NexusReader.Web.Services.Neo4jHealthCheck>("Neo4j");
|
||||||
|
|
||||||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
||||||
NexusReader.Application.DependencyInjection.Assembly,
|
NexusReader.Application.DependencyInjection.Assembly,
|
||||||
@@ -117,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";
|
||||||
@@ -295,6 +311,7 @@ if (!allowRegistration || !allowPasswordReset)
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
|
app.MapHealthChecks("/health");
|
||||||
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||||
|
|
||||||
// API endpoint for WASM client to fetch EPUB content
|
// API endpoint for WASM client to fetch EPUB content
|
||||||
@@ -514,18 +531,15 @@ app.MapGet("/api/creator/books/{bookId:guid}/revisions", async (Guid bookId, Cla
|
|||||||
|
|
||||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId));
|
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId));
|
||||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||||
|
|
||||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||||
return Results.BadRequest(errorMsg);
|
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
|
||||||
}
|
|
||||||
catch (NexusReader.Domain.Exceptions.BookNotFoundException)
|
|
||||||
{
|
{
|
||||||
return Results.NotFound($"Book with ID '{bookId}' was not found.");
|
return Results.NotFound(errorMsg);
|
||||||
}
|
}
|
||||||
|
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) =>
|
||||||
@@ -540,18 +554,15 @@ 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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId));
|
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId));
|
||||||
if (result.IsSuccess) return Results.Ok();
|
if (result.IsSuccess) return Results.Ok();
|
||||||
|
|
||||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||||
return Results.BadRequest(errorMsg);
|
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
|
||||||
}
|
|
||||||
catch (NexusReader.Domain.Exceptions.BookNotFoundException)
|
|
||||||
{
|
{
|
||||||
return Results.NotFound($"Book with ID '{bookId}' was not found.");
|
return Results.NotFound(errorMsg);
|
||||||
}
|
}
|
||||||
|
return Results.BadRequest(errorMsg);
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapPost("/api/creator/books", async (
|
app.MapPost("/api/creator/books", async (
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Services;
|
||||||
|
|
||||||
|
public class DatabaseHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _dbContext;
|
||||||
|
|
||||||
|
public DatabaseHealthCheck(AppDbContext dbContext)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var canConnect = await _dbContext.Database.CanConnectAsync(cancellationToken);
|
||||||
|
if (canConnect)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Healthy("Database is accessible.");
|
||||||
|
}
|
||||||
|
return HealthCheckResult.Unhealthy("Cannot connect to the database.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Database health check failed with exception.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Neo4j.Driver;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Services;
|
||||||
|
|
||||||
|
public class Neo4jHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IDriver _driver;
|
||||||
|
|
||||||
|
public Neo4jHealthCheck(IDriver driver)
|
||||||
|
{
|
||||||
|
_driver = driver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _driver.VerifyConnectivityAsync();
|
||||||
|
return HealthCheckResult.Healthy("Neo4j database is accessible.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Neo4j database connectivity check failed.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Qdrant.Client;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Services;
|
||||||
|
|
||||||
|
public class QdrantHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly QdrantClient _qdrantClient;
|
||||||
|
|
||||||
|
public QdrantHealthCheck(QdrantClient qdrantClient)
|
||||||
|
{
|
||||||
|
_qdrantClient = qdrantClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Simple check: query collection existence to verify connection is alive
|
||||||
|
_ = await _qdrantClient.CollectionExistsAsync("knowledge_units", cancellationToken);
|
||||||
|
return HealthCheckResult.Healthy("Qdrant database is accessible.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Qdrant database health check failed.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,7 +169,7 @@ public class PublishBookVersionTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WithMismatchedTenantId_ThrowsBookNotFoundException()
|
public async Task Handle_WithMismatchedTenantId_ReturnsFailure()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var bookId = Guid.NewGuid();
|
var bookId = Guid.NewGuid();
|
||||||
@@ -210,13 +210,16 @@ public class PublishBookVersionTests : IDisposable
|
|||||||
|
|
||||||
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
|
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
|
||||||
|
|
||||||
// Act & Assert
|
// Act
|
||||||
var action = () => handler.Handle(command, CancellationToken.None);
|
var result = await 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_ThrowsBookNotFoundException()
|
public async Task Handle_WithMismatchedUserId_ReturnsFailure()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var bookId = Guid.NewGuid();
|
var bookId = Guid.NewGuid();
|
||||||
@@ -257,13 +260,16 @@ public class PublishBookVersionTests : IDisposable
|
|||||||
|
|
||||||
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
|
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
|
||||||
|
|
||||||
// Act & Assert
|
// Act
|
||||||
var action = () => handler.Handle(command, CancellationToken.None);
|
var result = await 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_ThrowsBookNotFoundException()
|
public async Task Handle_WithNonExistentBook_ReturnsFailure()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new PublishBookVersionCommand(
|
var command = new PublishBookVersionCommand(
|
||||||
@@ -275,9 +281,12 @@ public class PublishBookVersionTests : IDisposable
|
|||||||
|
|
||||||
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
|
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
|
||||||
|
|
||||||
// Act & Assert
|
// Act
|
||||||
var action = () => handler.Handle(command, CancellationToken.None);
|
var result = await 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_ThrowsBookNotFoundException()
|
public async Task GetBookRevisions_WithMismatchedUserOrTenant_ReturnsFailure()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var userId = "creator-123";
|
var userId = "creator-123";
|
||||||
@@ -262,12 +262,14 @@ 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 actionTenant = () => handler.Handle(queryMismatchedTenant, CancellationToken.None);
|
var resultTenant = await handler.Handle(queryMismatchedTenant, CancellationToken.None);
|
||||||
await actionTenant.Should().ThrowAsync<BookNotFoundException>();
|
resultTenant.IsSuccess.Should().BeFalse();
|
||||||
|
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 actionUser = () => handler.Handle(queryMismatchedUser, CancellationToken.None);
|
var resultUser = await handler.Handle(queryMismatchedUser, CancellationToken.None);
|
||||||
await actionUser.Should().ThrowAsync<BookNotFoundException>();
|
resultUser.IsSuccess.Should().BeFalse();
|
||||||
|
resultUser.Errors.Should().Contain(e => e.Message.Contains("was not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -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