Compare commits
10 Commits
bcd5daa3a0
...
ce923ab72a
| Author | SHA1 | Date | |
|---|---|---|---|
| ce923ab72a | |||
| f6277bacfe | |||
| 1eacf7ed93 | |||
| dab698ee72 | |||
| c54ece9bd6 | |||
| ce4687ee93 | |||
| 94f6fe366d | |||
| e9bb51af77 | |||
| 93133a49b6 | |||
| faf6ec826e |
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to user library ownership details, decoupling the relational database
|
||||
/// structures from vector search and intelligence query operations.
|
||||
/// </summary>
|
||||
public interface IUserLibraryStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a list of book IDs that are owned by or uploaded for the specified user.
|
||||
/// </summary>
|
||||
Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a dictionary mapping book IDs to their titles.
|
||||
/// </summary>
|
||||
Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Decoupled database store to retrieve active user reading states and chapter content.
|
||||
/// </summary>
|
||||
public interface IUserReadingStateStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the user's active reading state: last read ebook ID, last opened chapter/page ID, and tenant ID.
|
||||
/// </summary>
|
||||
Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the text content of a specific chapter/page by its ID.
|
||||
/// </summary>
|
||||
Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chunk of text retrieved from the semantic vector database.
|
||||
/// </summary>
|
||||
public record VectorChunk(string Content, string EbookId, double Score, string MetadataJson = "", string BookTitle = "", string ChapterTitle = "");
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for performing semantic vector searches, isolating Qdrant gRPC dependencies from the Application layer.
|
||||
/// </summary>
|
||||
public interface IVectorSearchStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches.
|
||||
/// </summary>
|
||||
Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches within a whitelist of owned book IDs for the best semantic matches.
|
||||
/// </summary>
|
||||
Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches, excluding a specific book ID.
|
||||
/// </summary>
|
||||
Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
using NexusReader.Application.Queries.Intelligence;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
@@ -13,6 +14,7 @@ public interface IKnowledgeService
|
||||
Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
||||
Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default);
|
||||
Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface IUserPreferenceStore
|
||||
{
|
||||
Task<Result> SaveThemePreferenceAsync(ThemeMode mode);
|
||||
Task<Result<ThemeMode>> GetThemePreferenceAsync();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Application.Commands.User;
|
||||
|
||||
public record UpdateThemeCommand(string UserId, ThemeMode Mode) : ICommand;
|
||||
@@ -0,0 +1,41 @@
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Application.Commands.User;
|
||||
|
||||
public class UpdateThemeCommandHandler : ICommandHandler<UpdateThemeCommand>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public UpdateThemeCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(UpdateThemeCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var user = await dbContext.Users
|
||||
.AsTracking()
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Fail("User not found.");
|
||||
}
|
||||
|
||||
user.ThemePreference = request.Mode;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Failed to save theme preference in database.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Collections.Generic;
|
||||
using NexusReader.Application.Queries.Graph;
|
||||
using NexusReader.Application.Queries.Intelligence;
|
||||
|
||||
namespace NexusReader.Application.Common;
|
||||
|
||||
@@ -9,6 +11,13 @@ namespace NexusReader.Application.Common;
|
||||
[JsonSerializable(typeof(GraphDataDto))]
|
||||
[JsonSerializable(typeof(List<GraphNodeDto>))]
|
||||
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
||||
[JsonSerializable(typeof(GetGlobalIntelligenceRequest))]
|
||||
[JsonSerializable(typeof(IntelligenceResponse))]
|
||||
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))]
|
||||
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))]
|
||||
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
|
||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.User.UpdateThemeRequest))]
|
||||
[JsonSerializable(typeof(NexusReader.Domain.Enums.ThemeMode))]
|
||||
public partial class AppJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace NexusReader.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Configurations for the monetization engine, controlling the thresholds at which
|
||||
/// search queries trigger paywalls.
|
||||
/// </summary>
|
||||
public class RagMonetizationOptions
|
||||
{
|
||||
public const string SectionName = "RagMonetization";
|
||||
|
||||
/// <summary>
|
||||
/// The baseline score threshold above which global content might trigger a paywall if there is no local content.
|
||||
/// Default: 0.45.
|
||||
/// </summary>
|
||||
public double BaselineThreshold { get; set; } = 0.45;
|
||||
|
||||
/// <summary>
|
||||
/// The similarity gap (Delta) required between global and local content to trigger an upgrade paywall.
|
||||
/// Default: 0.15.
|
||||
/// </summary>
|
||||
public double DeltaThreshold { get; set; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// The absolute score required from global content to trigger an upgrade paywall.
|
||||
/// Default: 0.70.
|
||||
/// </summary>
|
||||
public double UpgradeThreshold { get; set; } = 0.70;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Application.DTOs.User;
|
||||
|
||||
public record UpdateThemeRequest(ThemeMode Mode);
|
||||
@@ -1,4 +1,5 @@
|
||||
using NexusReader.Application.Constants;
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Application.DTOs.User;
|
||||
|
||||
@@ -8,6 +9,7 @@ public record UserProfileDto
|
||||
public string UserId { get; init; } = string.Empty;
|
||||
public int AITokensUsed { get; init; }
|
||||
public Guid TenantId { get; init; }
|
||||
public ThemeMode ThemePreference { get; init; } = ThemeMode.System;
|
||||
|
||||
/// <summary>
|
||||
/// Relational data for the current subscription plan.
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Common;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
|
||||
namespace NexusReader.Application.Queries.Intelligence;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR query to request global intelligence hybrid Q&A context.
|
||||
/// </summary>
|
||||
public record GetGlobalIntelligenceQuery(string QueryText, string UserId, string TenantId = "global")
|
||||
: IRequest<Result<IntelligenceResponse>>;
|
||||
|
||||
/// <summary>
|
||||
/// Request schema for global hybrid search queries.
|
||||
/// </summary>
|
||||
public record GetGlobalIntelligenceRequest(string QueryText);
|
||||
|
||||
/// <summary>
|
||||
/// Response schema returning generated AI text, paywall status, and locked publishing details.
|
||||
/// </summary>
|
||||
public record IntelligenceResponse(
|
||||
string ResponseText,
|
||||
bool HasPaywall,
|
||||
Guid? LockedBookId,
|
||||
string? LockedBookTitle,
|
||||
List<CitationDto>? Citations = null);
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetGlobalIntelligenceQuery"/> by performing local/global dual searches,
|
||||
/// executing monetization rules, and invoking Chat AI with appropriate gating logic.
|
||||
/// </summary>
|
||||
public class GetGlobalIntelligenceQueryHandler : IRequestHandler<GetGlobalIntelligenceQuery, Result<IntelligenceResponse>>
|
||||
{
|
||||
private readonly IUserLibraryStore _userLibraryStore;
|
||||
private readonly IVectorSearchStore _vectorSearchStore;
|
||||
private readonly IChatClient _chatClient;
|
||||
private readonly RagMonetizationOptions _options;
|
||||
|
||||
public GetGlobalIntelligenceQueryHandler(
|
||||
IUserLibraryStore userLibraryStore,
|
||||
IVectorSearchStore vectorSearchStore,
|
||||
IChatClient chatClient,
|
||||
IOptions<RagMonetizationOptions> options)
|
||||
{
|
||||
_userLibraryStore = userLibraryStore;
|
||||
_vectorSearchStore = vectorSearchStore;
|
||||
_chatClient = chatClient;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<Result<IntelligenceResponse>> Handle(GetGlobalIntelligenceQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.QueryText))
|
||||
{
|
||||
return Result.Fail("Question cannot be empty.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Step A: Fetch whitelisted BookIds
|
||||
var whitelistedBookIds = await _userLibraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken);
|
||||
|
||||
// Step B & C: Vector Dual-Search with Resilient Trapping
|
||||
List<VectorChunk> globalChunks = new();
|
||||
List<VectorChunk> localChunks = new();
|
||||
double globalScore = 0.0;
|
||||
double localScore = 0.0;
|
||||
|
||||
try
|
||||
{
|
||||
// Execute searches
|
||||
globalChunks = await _vectorSearchStore.SearchGlobalAsync(request.QueryText, request.TenantId, limit: 3, cancellationToken);
|
||||
globalScore = globalChunks.Any() ? Math.Max(0.0, globalChunks.Max(c => c.Score)) : 0.0;
|
||||
|
||||
if (whitelistedBookIds.Any())
|
||||
{
|
||||
localChunks = await _vectorSearchStore.SearchLocalAsync(request.QueryText, request.TenantId, whitelistedBookIds, limit: 3, cancellationToken);
|
||||
localScore = localChunks.Any() ? Math.Max(0.0, localChunks.Max(c => c.Score)) : 0.0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Resilient Error Trapping: transform connectivity anomalies into domain-friendly errors
|
||||
return Result.Fail(new Error("Serwer wyszukiwania semantycznego jest tymczasowo niedostępny. Spróbuj ponownie później.").CausedBy(ex));
|
||||
}
|
||||
|
||||
// Step D: Evaluate Monetization Thresholds
|
||||
bool triggerPaywall = false;
|
||||
|
||||
if (localScore == 0.0 && globalScore > _options.BaselineThreshold)
|
||||
{
|
||||
triggerPaywall = true;
|
||||
}
|
||||
else if ((globalScore - localScore) > _options.DeltaThreshold && globalScore > _options.UpgradeThreshold)
|
||||
{
|
||||
triggerPaywall = true;
|
||||
}
|
||||
|
||||
var chosenChunks = triggerPaywall ? globalChunks : localChunks;
|
||||
|
||||
// Fetch book titles for citations/paywall metadata
|
||||
var chunkEbookIds = chosenChunks
|
||||
.Where(c => Guid.TryParse(c.EbookId, out _))
|
||||
.Select(c => Guid.Parse(c.EbookId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var bookTitles = await _userLibraryStore.GetBookTitlesAsync(chunkEbookIds, cancellationToken);
|
||||
|
||||
// Step E: Identify locked book if paywall triggered
|
||||
Guid? lockedBookId = null;
|
||||
string? lockedBookTitle = null;
|
||||
|
||||
if (triggerPaywall && globalChunks.Any())
|
||||
{
|
||||
var topGlobalChunk = globalChunks.OrderByDescending(c => c.Score).First();
|
||||
if (Guid.TryParse(topGlobalChunk.EbookId, out var parsedLockedId))
|
||||
{
|
||||
lockedBookId = parsedLockedId;
|
||||
bookTitles.TryGetValue(parsedLockedId, out lockedBookTitle);
|
||||
if (string.IsNullOrEmpty(lockedBookTitle))
|
||||
{
|
||||
lockedBookTitle = "Nieznana książka";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format context blocks for LLM
|
||||
var relatedContexts = new List<string>();
|
||||
foreach (var chunk in chosenChunks)
|
||||
{
|
||||
var sourceId = chunk.EbookId;
|
||||
relatedContexts.Add($"[Source ID: {sourceId}] {chunk.Content}");
|
||||
}
|
||||
var contextBlocksText = string.Join("\n\n", relatedContexts);
|
||||
|
||||
// Build LLM prompts
|
||||
var systemPrompt = "You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks.\n" +
|
||||
"Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions.\n" +
|
||||
"If the context does not contain the answer, say: 'I cannot answer this based on the provided book context.'";
|
||||
|
||||
if (triggerPaywall)
|
||||
{
|
||||
var localScorePercent = (int)Math.Round(localScore * 100);
|
||||
var globalScorePercent = (int)Math.Round(globalScore * 100);
|
||||
var resolvedTitle = lockedBookTitle ?? "Nieznana książka";
|
||||
|
||||
systemPrompt += $"\n\nCRITICAL: You are operating in TEASER mode. The user does not own the source document named '{resolvedTitle}'. You are strictly allowed to provide only a 1-sentence foundational definition or answer based on the context to prove the system knows the solution. DO NOT output code blocks, implementation details, or bullet points. You must immediately terminate your response with this exact token format: [PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}].";
|
||||
}
|
||||
|
||||
var messages = new List<Microsoft.Extensions.AI.ChatMessage>
|
||||
{
|
||||
new(Microsoft.Extensions.AI.ChatRole.System, systemPrompt),
|
||||
new(Microsoft.Extensions.AI.ChatRole.User, $"Context:\n{contextBlocksText}\n\nQuestion: {request.QueryText}")
|
||||
};
|
||||
|
||||
var chatOptions = new ChatOptions
|
||||
{
|
||||
Temperature = 0.0f,
|
||||
MaxOutputTokens = 1000
|
||||
};
|
||||
|
||||
var chatResponse = await _chatClient.GetResponseAsync(messages, chatOptions, cancellationToken);
|
||||
var responseText = chatResponse.Text?.Trim() ?? string.Empty;
|
||||
|
||||
// Ensure the paywall token is appended if LLM misses it in teaser mode
|
||||
if (triggerPaywall)
|
||||
{
|
||||
var localScorePercent = (int)Math.Round(localScore * 100);
|
||||
var globalScorePercent = (int)Math.Round(globalScore * 100);
|
||||
var resolvedTitle = lockedBookTitle ?? "Nieznana książka";
|
||||
var paywallToken = $"[PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}]";
|
||||
|
||||
if (!responseText.Contains("[PAYWALL_TRIGGER:"))
|
||||
{
|
||||
responseText = responseText.Trim() + " " + paywallToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Build citations list
|
||||
var citations = new List<CitationDto>();
|
||||
foreach (var chunk in chosenChunks)
|
||||
{
|
||||
var sourceBookName = "Unknown";
|
||||
if (Guid.TryParse(chunk.EbookId, out var parsedId) && bookTitles.TryGetValue(parsedId, out var title))
|
||||
{
|
||||
sourceBookName = title;
|
||||
}
|
||||
|
||||
citations.Add(new CitationDto
|
||||
{
|
||||
CitationId = chunk.EbookId,
|
||||
Snippet = chunk.Content,
|
||||
SourceBook = sourceBookName,
|
||||
Author = null,
|
||||
PageNumber = null
|
||||
});
|
||||
}
|
||||
|
||||
return Result.Ok(new IntelligenceResponse(
|
||||
ResponseText: responseText,
|
||||
HasPaywall: triggerPaywall,
|
||||
LockedBookId: lockedBookId,
|
||||
LockedBookTitle: lockedBookTitle,
|
||||
Citations: citations
|
||||
));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Nieoczekiwany błąd serwera podczas przetwarzania zapytania.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
|
||||
namespace NexusReader.Application.Queries.Recommendations;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR query to fetch contextual recommendations based on the user's active reading state.
|
||||
/// </summary>
|
||||
public record GetContextualRecommendationsQuery(string UserId)
|
||||
: IRequest<Result<ContextualRecommendationResponse>>;
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO containing contextual recommendations.
|
||||
/// </summary>
|
||||
public record ContextualRecommendationResponse(List<RecommendationDto> Recommendations);
|
||||
|
||||
/// <summary>
|
||||
/// Individual contextual recommendation details.
|
||||
/// </summary>
|
||||
public record RecommendationDto(
|
||||
string BookTitle,
|
||||
string ChapterTitle,
|
||||
int MatchPercentage,
|
||||
bool IsPremiumUpsell,
|
||||
Guid TargetBookId
|
||||
);
|
||||
@@ -27,6 +27,7 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
||||
UserId = u.Id,
|
||||
AITokensUsed = u.AITokensUsed,
|
||||
TenantIdString = u.TenantId,
|
||||
ThemePreference = u.ThemePreference,
|
||||
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
||||
{
|
||||
Id = u.SubscriptionPlan.Id,
|
||||
@@ -106,6 +107,7 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
||||
AverageQuizScore = averageQuizScore,
|
||||
DisplayName = userRaw.DisplayName,
|
||||
BooksReadCount = userRaw.BooksReadCount,
|
||||
ThemePreference = userRaw.ThemePreference,
|
||||
ConceptsMappedCount = conceptsMappedCount,
|
||||
LastReadBook = userRaw.LastReadBook,
|
||||
RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
|
||||
|
||||
+711
@@ -0,0 +1,711 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260607104453_AddThemePreference")]
|
||||
partial class AddThemePreference
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("AddedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("AuthorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsReadyForReading")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastChapter")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("LastChapterIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("LastReadDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Progress")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("EbookId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EbookId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("RelationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("SourceUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TargetUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceUnitId");
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AITokensUsed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastAiActionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastReadPageId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SubscriptionPlanId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("ThemePreference")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionPlanId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CompletedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("TotalQuestions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
{
|
||||
b.Property<string>("ContentHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("JsonData")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ModelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("OriginalText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PromptVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("PlanName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
|
||||
.WithMany()
|
||||
.HasForeignKey("EbookId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Ebook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||
.WithMany("OutgoingLinks")
|
||||
.HasForeignKey("SourceUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
||||
.WithMany("IncomingLinks")
|
||||
.HasForeignKey("TargetUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceUnit");
|
||||
|
||||
b.Navigation("TargetUnit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriptionPlanId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SubscriptionPlan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("QuizResults")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
b.Navigation("OutgoingLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
|
||||
b.Navigation("QuizResults");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddThemePreference : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Vector",
|
||||
table: "SemanticKnowledgeCache");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Vector",
|
||||
table: "KnowledgeUnits");
|
||||
|
||||
migrationBuilder.AlterDatabase()
|
||||
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ThemePreference",
|
||||
table: "AspNetUsers",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ThemePreference",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:PostgresExtension:vector", ",,");
|
||||
|
||||
migrationBuilder.AddColumn<Vector>(
|
||||
name: "Vector",
|
||||
table: "SemanticKnowledgeCache",
|
||||
type: "vector(1536)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Vector>(
|
||||
name: "Vector",
|
||||
table: "KnowledgeUnits",
|
||||
type: "vector(768)",
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
@@ -21,7 +20,6 @@ namespace NexusReader.Data.Migrations
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
@@ -264,9 +262,6 @@ namespace NexusReader.Data.Migrations
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(768)");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
@@ -388,6 +383,11 @@ namespace NexusReader.Data.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("ThemePreference")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -480,9 +480,6 @@ namespace NexusReader.Data.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(1536)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Data.Persistence;
|
||||
|
||||
@@ -43,6 +44,10 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
|
||||
entity.Property(u => u.SubscriptionPlanId)
|
||||
.HasDefaultValue(1);
|
||||
|
||||
entity.Property(u => u.ThemePreference)
|
||||
.HasConversion<int>()
|
||||
.HasDefaultValue(ThemeMode.System);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Domain.Entities;
|
||||
|
||||
@@ -65,4 +66,9 @@ public class NexusUser : IdentityUser
|
||||
/// Last read timestamp.
|
||||
/// </summary>
|
||||
public DateTime? LastReadAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User's visual theme preference.
|
||||
/// </summary>
|
||||
public ThemeMode ThemePreference { get; set; } = ThemeMode.System;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace NexusReader.Domain.Enums;
|
||||
|
||||
public enum ThemeMode
|
||||
{
|
||||
System = 0,
|
||||
Dark = 1,
|
||||
LightSepia = 2
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Application.Common;
|
||||
using GeminiDotnet;
|
||||
using GeminiDotnet.Extensions.AI;
|
||||
using NexusReader.Data.Persistence;
|
||||
@@ -76,6 +77,7 @@ public static class DependencyInjection
|
||||
|
||||
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||
services.Configure<RagMonetizationOptions>(configuration.GetSection(RagMonetizationOptions.SectionName));
|
||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
||||
@@ -127,6 +129,9 @@ public static class DependencyInjection
|
||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
|
||||
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
|
||||
services.AddScoped<IUserLibraryStore, UserLibraryStore>();
|
||||
services.AddScoped<IUserReadingStateStore, UserReadingStateStore>();
|
||||
services.AddScoped<IVectorSearchStore, VectorSearchStore>();
|
||||
|
||||
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
||||
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IUserLibraryStore"/> using <see cref="AppDbContext"/>.
|
||||
/// </summary>
|
||||
internal sealed class UserLibraryStore : IUserLibraryStore
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public UserLibraryStore(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Ebooks
|
||||
.Where(e => e.UserId == userId)
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (bookIds == null || !bookIds.Any())
|
||||
{
|
||||
return new Dictionary<Guid, string>();
|
||||
}
|
||||
|
||||
return await _context.Ebooks
|
||||
.Where(e => bookIds.Contains(e.Id))
|
||||
.ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IUserReadingStateStore"/>.
|
||||
/// </summary>
|
||||
internal sealed class UserReadingStateStore : IUserReadingStateStore
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public UserReadingStateStore(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var userState = await dbContext.Users
|
||||
.Where(u => u.Id == userId)
|
||||
.Select(u => new
|
||||
{
|
||||
u.TenantId,
|
||||
u.LastReadPageId,
|
||||
LastReadBookId = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => (Guid?)e.Id).FirstOrDefault()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (userState == null)
|
||||
{
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
return (userState.LastReadBookId, userState.LastReadPageId, userState.TenantId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
return await dbContext.KnowledgeUnits
|
||||
.Where(ku => ku.Id == chapterId)
|
||||
.Select(ku => ku.Content)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Qdrant.Client;
|
||||
using Qdrant.Client.Grpc;
|
||||
using Polly;
|
||||
using Polly.Registry;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Infrastructure implementation of <see cref="IVectorSearchStore"/> utilizing <see cref="QdrantClient"/>
|
||||
/// and <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> to execute semantic vector queries.
|
||||
/// </summary>
|
||||
internal sealed class VectorSearchStore : IVectorSearchStore
|
||||
{
|
||||
private readonly QdrantClient _qdrantClient;
|
||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||
private readonly ResiliencePipeline _retryPipeline;
|
||||
private readonly ILogger<VectorSearchStore> _logger;
|
||||
|
||||
public VectorSearchStore(
|
||||
QdrantClient qdrantClient,
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
||||
ResiliencePipelineProvider<string> pipelineProvider,
|
||||
ILogger<VectorSearchStore> logger)
|
||||
{
|
||||
_qdrantClient = qdrantClient;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
||||
var filter = BuildTenantFilter(tenantId);
|
||||
|
||||
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (whitelistedBookIds == null || !whitelistedBookIds.Any())
|
||||
{
|
||||
return new List<VectorChunk>();
|
||||
}
|
||||
|
||||
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
||||
var filter = BuildTenantFilter(tenantId);
|
||||
|
||||
var whitelistFilter = new Qdrant.Client.Grpc.Filter();
|
||||
foreach (var bookId in whitelistedBookIds)
|
||||
{
|
||||
whitelistFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "ebookId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = bookId.ToString() }
|
||||
}
|
||||
});
|
||||
}
|
||||
filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = whitelistFilter });
|
||||
|
||||
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
||||
var filter = BuildTenantFilter(tenantId);
|
||||
|
||||
// Exclude current book
|
||||
filter.MustNot.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "ebookId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = excludeBookId.ToString() }
|
||||
}
|
||||
});
|
||||
|
||||
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
_logger.LogWarning("[VectorSearchStore] Attempted to generate embedding from empty text. Returning zero vector.");
|
||||
return Array.Empty<float>();
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var response = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
await _embeddingGenerator.GenerateAsync(
|
||||
new[] { text },
|
||||
new EmbeddingGenerationOptions { Dimensions = 768 },
|
||||
cancellationToken: ct), cancellationToken);
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogDebug("[VectorSearchStore] Embedding generated in {ElapsedMs}ms for text of {Length} chars.", sw.ElapsedMilliseconds, text.Length);
|
||||
return response.First().Vector.ToArray();
|
||||
}
|
||||
|
||||
private Qdrant.Client.Grpc.Filter BuildTenantFilter(string tenantId)
|
||||
{
|
||||
var filter = new Qdrant.Client.Grpc.Filter();
|
||||
var tenantFilter = new Qdrant.Client.Grpc.Filter();
|
||||
|
||||
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "tenantId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = tenantId }
|
||||
}
|
||||
});
|
||||
|
||||
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
||||
{
|
||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
||||
{
|
||||
Key = "tenantId",
|
||||
Match = new Qdrant.Client.Grpc.Match { Text = "global" }
|
||||
}
|
||||
});
|
||||
|
||||
filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = tenantFilter });
|
||||
return filter;
|
||||
}
|
||||
|
||||
private async Task<List<VectorChunk>> ExecuteSearchAsync(float[] queryVector, Qdrant.Client.Grpc.Filter filter, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
if (queryVector.Length == 0)
|
||||
{
|
||||
_logger.LogWarning("[VectorSearchStore] Empty query vector — skipping Qdrant search.");
|
||||
return new List<VectorChunk>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var response = await _qdrantClient.SearchAsync(
|
||||
collectionName: "knowledge_units",
|
||||
vector: queryVector,
|
||||
filter: filter,
|
||||
limit: (ulong)limit,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
sw.Stop();
|
||||
_logger.LogInformation("[VectorSearchStore] Qdrant search returned {Count} results in {ElapsedMs}ms.", response.Count, sw.ElapsedMilliseconds);
|
||||
|
||||
return response.Select(point =>
|
||||
{
|
||||
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
|
||||
var ebookId = point.Payload.TryGetValue("ebookId", out var ev) ? ev.StringValue : string.Empty;
|
||||
var metadataJson = point.Payload.TryGetValue("metadataJson", out var mv) ? mv.StringValue : string.Empty;
|
||||
var bookTitle = point.Payload.TryGetValue("bookTitle", out var btv) ? btv.StringValue : string.Empty;
|
||||
var chapterTitle = point.Payload.TryGetValue("chapterTitle", out var ctv) ? ctv.StringValue : string.Empty;
|
||||
|
||||
return new VectorChunk(content, ebookId, point.Score, metadataJson, bookTitle, chapterTitle);
|
||||
}).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[VectorSearchStore] Qdrant search execution failed.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' does not exist — creating.", collectionName);
|
||||
await _qdrantClient.CreateCollectionAsync(
|
||||
collectionName: collectionName,
|
||||
vectorsConfig: new Qdrant.Client.Grpc.VectorParams
|
||||
{
|
||||
Size = 768,
|
||||
Distance = Distance.Cosine
|
||||
},
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' created successfully.", collectionName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log concurrent creation conflicts (e.g., AlreadyExists gRPC status) but do not propagate.
|
||||
_logger.LogWarning(ex, "[VectorSearchStore] Non-fatal error while ensuring collection '{CollectionName}' exists. Possible concurrent creation.", collectionName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Queries.Recommendations;
|
||||
|
||||
namespace NexusReader.Infrastructure.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetContextualRecommendationsQuery"/> by discovering the active reading state,
|
||||
/// performing semantic search using <see cref="IVectorSearchStore"/> with book exclusion, and mapping upsells.
|
||||
/// </summary>
|
||||
public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetContextualRecommendationsQuery, Result<ContextualRecommendationResponse>>
|
||||
{
|
||||
private readonly IUserReadingStateStore _readingStateStore;
|
||||
private readonly IUserLibraryStore _libraryStore;
|
||||
private readonly IVectorSearchStore _vectorSearchStore;
|
||||
private readonly ILogger<GetContextualRecommendationsQueryHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="GetContextualRecommendationsQueryHandler"/>.
|
||||
/// </summary>
|
||||
public GetContextualRecommendationsQueryHandler(
|
||||
IUserReadingStateStore readingStateStore,
|
||||
IUserLibraryStore libraryStore,
|
||||
IVectorSearchStore vectorSearchStore,
|
||||
ILogger<GetContextualRecommendationsQueryHandler> logger)
|
||||
{
|
||||
_readingStateStore = readingStateStore;
|
||||
_libraryStore = libraryStore;
|
||||
_vectorSearchStore = vectorSearchStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<ContextualRecommendationResponse>> Handle(GetContextualRecommendationsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.UserId))
|
||||
{
|
||||
return Result.Fail("UserId cannot be empty.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Discover active reading state
|
||||
var (ebookId, chapterId, tenantId) = await _readingStateStore.GetActiveReadingStateAsync(request.UserId, cancellationToken);
|
||||
if (ebookId == null)
|
||||
{
|
||||
_logger.LogInformation("[Recommendations] No active reading state for user {UserId}. Returning empty list.", request.UserId);
|
||||
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
|
||||
}
|
||||
|
||||
// Step 2: Fetch specific content associated with active ChapterId
|
||||
string? chapterContent = null;
|
||||
if (!string.IsNullOrEmpty(chapterId))
|
||||
{
|
||||
chapterContent = await _readingStateStore.GetChapterContentAsync(chapterId, cancellationToken);
|
||||
}
|
||||
|
||||
// Guard: empty chapter content cannot produce a meaningful embedding
|
||||
if (string.IsNullOrWhiteSpace(chapterContent))
|
||||
{
|
||||
_logger.LogWarning("[Recommendations] Chapter content is empty for chapterId={ChapterId}. Returning empty list.", chapterId);
|
||||
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
|
||||
}
|
||||
|
||||
// Step 3: Perform similarity search using IVectorSearchStore
|
||||
var resolvedTenantId = tenantId ?? "global";
|
||||
_logger.LogDebug("[Recommendations] Performing vector search for user {UserId}, book {EbookId}, tenant {TenantId}.", request.UserId, ebookId, resolvedTenantId);
|
||||
|
||||
var searchResults = await _vectorSearchStore.SearchGlobalExcludeAsync(
|
||||
chapterContent,
|
||||
resolvedTenantId,
|
||||
ebookId.Value,
|
||||
limit: 2,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
// Step 4: Process recommendations and cross-reference owned books
|
||||
var ownedBookIds = await _libraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken);
|
||||
var recommendations = new List<RecommendationDto>();
|
||||
|
||||
foreach (var point in searchResults)
|
||||
{
|
||||
var targetEbookIdStr = point.EbookId;
|
||||
if (!Guid.TryParse(targetEbookIdStr, out var targetEbookId))
|
||||
continue;
|
||||
|
||||
// Load bookTitle from point
|
||||
var bookTitle = point.BookTitle;
|
||||
if (string.IsNullOrEmpty(bookTitle))
|
||||
{
|
||||
bookTitle = "Nieznana książka";
|
||||
}
|
||||
|
||||
// Load chapterTitle from point or metadataJson
|
||||
var chapterTitle = point.ChapterTitle;
|
||||
if (string.IsNullOrEmpty(chapterTitle))
|
||||
{
|
||||
chapterTitle = "Wiedza z rozdziału";
|
||||
if (!string.IsNullOrEmpty(point.MetadataJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(point.MetadataJson);
|
||||
if (doc.RootElement.TryGetProperty("label", out var labelProp))
|
||||
{
|
||||
chapterTitle = labelProp.GetString() ?? chapterTitle;
|
||||
}
|
||||
}
|
||||
catch (JsonException jsonEx)
|
||||
{
|
||||
_logger.LogWarning(jsonEx, "[Recommendations] Failed to parse metadataJson for chunk with ebookId={EbookId}.", targetEbookIdStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isPremiumUpsell = !ownedBookIds.Contains(targetEbookId);
|
||||
var matchPercentage = (int)Math.Round(point.Score * 100);
|
||||
|
||||
recommendations.Add(new RecommendationDto(
|
||||
BookTitle: bookTitle,
|
||||
ChapterTitle: chapterTitle,
|
||||
MatchPercentage: matchPercentage,
|
||||
IsPremiumUpsell: isPremiumUpsell,
|
||||
TargetBookId: targetEbookId
|
||||
));
|
||||
}
|
||||
|
||||
_logger.LogInformation("[Recommendations] Returning {Count} recommendations for user {UserId}.", recommendations.Count, request.UserId);
|
||||
return Result.Ok(new ContextualRecommendationResponse(recommendations));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Recommendations] Downstream vector database or state query failed for user {UserId}.", request.UserId);
|
||||
return Result.Fail(new Error("Downstream vector database or state query failed.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MediatR;
|
||||
using NexusReader.Application.Queries.Intelligence;
|
||||
using Microsoft.ML.Tokenizers;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
@@ -33,6 +35,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
private readonly ILogger<KnowledgeService> _logger;
|
||||
private readonly QdrantClient _qdrantClient;
|
||||
private readonly IDriver _neo4jDriver;
|
||||
private readonly IMediator _mediator;
|
||||
private const string PromptVersion = "1.7";
|
||||
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
||||
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
|
||||
@@ -45,7 +48,8 @@ public class KnowledgeService : IKnowledgeService
|
||||
IOptions<AiSettings> settings,
|
||||
ILogger<KnowledgeService> logger,
|
||||
QdrantClient qdrantClient,
|
||||
IDriver neo4jDriver)
|
||||
IDriver neo4jDriver,
|
||||
IMediator mediator)
|
||||
{
|
||||
_chatClient = chatClient;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
@@ -55,6 +59,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
_logger = logger;
|
||||
_qdrantClient = qdrantClient;
|
||||
_neo4jDriver = neo4jDriver;
|
||||
_mediator = mediator;
|
||||
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
||||
// a very reliable estimation for token usage in Gemini-based workloads.
|
||||
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
||||
@@ -334,6 +339,17 @@ public class KnowledgeService : IKnowledgeService
|
||||
{
|
||||
try
|
||||
{
|
||||
// Retrieve the book's title from the database using EF Core
|
||||
string bookTitle = "Nieznana książka";
|
||||
if (ebookId.HasValue)
|
||||
{
|
||||
var ebook = await dbContext.Ebooks.FindAsync(new object[] { ebookId.Value }, cancellationToken);
|
||||
if (ebook != null)
|
||||
{
|
||||
bookTitle = ebook.Title;
|
||||
}
|
||||
}
|
||||
|
||||
var contents = unitsToEmbed.Select(u => u.Content).ToList();
|
||||
|
||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
@@ -350,6 +366,12 @@ public class KnowledgeService : IKnowledgeService
|
||||
var unitDto = unitsToEmbed[i];
|
||||
var vector = embeddings[i].Vector.ToArray();
|
||||
|
||||
string chapterTitle = "Wiedza z rozdziału";
|
||||
if (unitDto.Metadata != null && unitDto.Metadata.TryGetValue("label", out var labelVal) && labelVal is string labelStr)
|
||||
{
|
||||
chapterTitle = labelStr;
|
||||
}
|
||||
|
||||
var point = new PointStruct
|
||||
{
|
||||
Id = GetDeterministicGuid(unitDto.Id),
|
||||
@@ -360,6 +382,8 @@ public class KnowledgeService : IKnowledgeService
|
||||
["type"] = unitDto.Type ?? string.Empty,
|
||||
["tenantId"] = tenantId,
|
||||
["ebookId"] = ebookId?.ToString() ?? string.Empty,
|
||||
["bookTitle"] = bookTitle,
|
||||
["chapterTitle"] = chapterTitle,
|
||||
["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata)
|
||||
}
|
||||
};
|
||||
@@ -1187,6 +1211,12 @@ public class KnowledgeService : IKnowledgeService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _mediator.Send(new GetGlobalIntelligenceQuery(queryText, userId, tenantId), cancellationToken);
|
||||
}
|
||||
|
||||
private int EstimateTokenCount(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return 0;
|
||||
|
||||
@@ -8,6 +8,7 @@ using NexusReader.Application;
|
||||
using MediatR;
|
||||
using NexusReader.Maui.Infrastructure.Logging;
|
||||
using NexusReader.Maui.Infrastructure.Identity;
|
||||
using NexusReader.Maui.Services;
|
||||
|
||||
namespace NexusReader.Maui;
|
||||
|
||||
@@ -44,7 +45,7 @@ public static class MauiProgram
|
||||
|
||||
// Minimal Infrastructure
|
||||
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
|
||||
builder.Services.AddSingleton<INativeStorageService, MauiStorageService>();
|
||||
builder.Services.AddSingleton<INativeStorageService, NexusReader.Infrastructure.Mobile.Services.MauiStorageService>();
|
||||
|
||||
// Minimal Identity (Safe Mode)
|
||||
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
|
||||
@@ -56,7 +57,7 @@ public static class MauiProgram
|
||||
builder.Services.AddTransient<MobileAuthenticationHeaderHandler>();
|
||||
builder.Services.AddHttpClient("NexusAPI", client =>
|
||||
{
|
||||
var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5000";
|
||||
var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5104";
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
}).AddHttpMessageHandler<MobileAuthenticationHeaderHandler>();
|
||||
|
||||
@@ -67,13 +68,15 @@ public static class MauiProgram
|
||||
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||
builder.Services.AddSingleton(featureSettings);
|
||||
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddSingleton<IUserPreferenceStore, MauiUserPreferenceStore>();
|
||||
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
|
||||
builder.Services.AddScoped<ILibraryStateService, LibraryStateService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using FluentResults;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Domain.Enums;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Maui.Services;
|
||||
|
||||
public class MauiUserPreferenceStore : IUserPreferenceStore
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public MauiUserPreferenceStore(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
private HttpClient CreateClient() => _httpClientFactory.CreateClient("NexusAPI");
|
||||
|
||||
public async Task<Result> SaveThemePreferenceAsync(ThemeMode mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = CreateClient();
|
||||
var response = await client.PostAsJsonAsync("identity/theme", new UpdateThemeRequest(mode));
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
return Result.Fail($"Failed to save cloud theme preference on mobile: {error}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Network error saving mobile theme preference to cloud.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<ThemeMode>> GetThemePreferenceAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = CreateClient();
|
||||
var response = await client.GetAsync("identity/profile");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var profile = await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||
return profile != null
|
||||
? Result.Ok(profile.ThemePreference)
|
||||
: Result.Fail("Failed to deserialize mobile profile response.");
|
||||
}
|
||||
return Result.Fail($"Failed to fetch theme preference from cloud on mobile: {response.ReasonPhrase}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Network error retrieving theme preference on mobile.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"ApiSettings": {
|
||||
"BaseUrl": "https://localhost:5000"
|
||||
"BaseUrl": "http://localhost:5104"
|
||||
},
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
|
||||
@@ -9,13 +9,28 @@
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<script>
|
||||
(function () {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isLight = savedTheme === 'light' || (!savedTheme && !systemPrefersDark);
|
||||
if (isLight) {
|
||||
document.documentElement.classList.add('theme-light');
|
||||
} else {
|
||||
document.documentElement.classList.remove('theme-light');
|
||||
try {
|
||||
var themeMode = localStorage.getItem('theme-mode');
|
||||
var savedTheme = localStorage.getItem('theme');
|
||||
var isLight = false;
|
||||
|
||||
if (themeMode === '2' || savedTheme === 'light') {
|
||||
isLight = true;
|
||||
} else if (themeMode === '1' || savedTheme === 'dark') {
|
||||
isLight = false;
|
||||
} else {
|
||||
isLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||
}
|
||||
|
||||
if (isLight) {
|
||||
document.documentElement.classList.add('theme-light');
|
||||
document.documentElement.classList.remove('theme-dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('theme-dark');
|
||||
document.documentElement.classList.remove('theme-light');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fail silently
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject IQuizStateService QuizState
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@inject ILogger<AiAssistantBubble> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<div class="ai-bubble-container">
|
||||
@@ -134,7 +136,7 @@
|
||||
catch (Exception ex)
|
||||
{
|
||||
_displayedText = string.IsNullOrEmpty(Dialogue) ? "Błąd analizy." : Dialogue;
|
||||
Console.WriteLine($"[AiAssistantBubble] Error fetching summary: {ex.Message}");
|
||||
Logger.LogError(ex, "[AiAssistantBubble] Error fetching summary for block {BlockId}.", ContextBlockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
@using NexusReader.UI.Shared.Models
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Net.Http.Json
|
||||
@inject HttpClient Http
|
||||
@inject ILibraryStateService LibraryStateService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<AiResponseRenderer> Logger
|
||||
|
||||
<div class="message-row @(Message.Sender == "User" ? "user-row" : "ai-row")">
|
||||
<div class="message-avatar" aria-hidden="true">
|
||||
@if (Message.Sender == "User")
|
||||
{
|
||||
<i class="bi bi-person-fill"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-robot"></i>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="message-bubble @GetBubbleClass()">
|
||||
<div class="message-header">
|
||||
<span class="sender-name">@Message.Sender</span>
|
||||
<span class="message-time">@Message.Timestamp.ToString("HH:mm")</span>
|
||||
</div>
|
||||
|
||||
<div class="message-content">
|
||||
@if (Message.Sender == "User")
|
||||
{
|
||||
<p>@Message.Text</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_hasPaywall)
|
||||
{
|
||||
<div class="paywall-teaser" aria-hidden="true">
|
||||
@foreach (var segment in ParseSegments(_displayTeaserText))
|
||||
{
|
||||
@if (segment.IsCitation)
|
||||
{
|
||||
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@RenderMarkdown(segment.Text)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="upsell-card" role="alert" aria-live="polite">
|
||||
<div class="upsell-header">
|
||||
<span class="upsell-icon" aria-hidden="true">🔒</span>
|
||||
<h4>Dostęp Premium Zablokowany</h4>
|
||||
</div>
|
||||
|
||||
<p class="upsell-text">
|
||||
Twoje zasoby odpowiadają na to pytanie w <strong>@_localScore%</strong>. W materiale <strong>'@_lockedBookTitle'</strong> znaleźliśmy odpowiedź dopasowaną w <strong>@_globalScore%</strong>.
|
||||
</p>
|
||||
|
||||
<div class="upsell-actions">
|
||||
@if (_isSimulatingPayment)
|
||||
{
|
||||
<button class="btn-upsell btn-primary loading" disabled aria-busy="true">
|
||||
<div class="payment-spinner" aria-hidden="true"></div>
|
||||
PRZETWARZANIE PŁATNOŚCI...
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn-upsell btn-primary" @onclick="HandlePurchase">
|
||||
ODBLOKUJ PEŁNĄ TREŚĆ (29 PLN)
|
||||
</button>
|
||||
}
|
||||
<a href="/catalog?bookId=@_lockedBookId" class="btn-upsell btn-secondary">
|
||||
Zobacz szczegóły w Katalogu
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="full-response">
|
||||
@foreach (var segment in ParseSegments(GetCleanText()))
|
||||
{
|
||||
@if (segment.IsCitation)
|
||||
{
|
||||
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@RenderMarkdown(segment.Text)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_showSuccessBanner)
|
||||
{
|
||||
<div class="success-unlock-banner" role="status">
|
||||
<span class="success-icon" aria-hidden="true">✓</span>
|
||||
<span>Odblokowano pełną odpowiedź! Książka została dodana do Twojej biblioteki.</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public ChatMessage Message { get; set; } = default!;
|
||||
[Parameter] public List<LastReadBookDto>? OwnedBooks { get; set; }
|
||||
[Parameter] public EventCallback<Guid> OnUnlockRequested { get; set; }
|
||||
|
||||
private bool _hasPaywall;
|
||||
private string _displayTeaserText = string.Empty;
|
||||
private Guid _lockedBookId;
|
||||
private string _lockedBookTitle = string.Empty;
|
||||
private int _localScore;
|
||||
private int _globalScore;
|
||||
|
||||
private bool _isUnlocked = false;
|
||||
private bool _isSimulatingPayment = false;
|
||||
private bool _showSuccessBanner = false;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
base.OnParametersSet();
|
||||
|
||||
if (Message != null && Message.Sender != "User" && !_isUnlocked)
|
||||
{
|
||||
_hasPaywall = PaywallParser.TryParsePaywallTrigger(Message.Text, out _displayTeaserText, out _lockedBookId, out _lockedBookTitle, out _localScore, out _globalScore);
|
||||
|
||||
// Additional check: if user already owns the book, don't show the paywall
|
||||
if (_hasPaywall && OwnedBooks != null)
|
||||
{
|
||||
var isOwned = OwnedBooks.Any(b =>
|
||||
b.Id == _lockedBookId ||
|
||||
(!string.IsNullOrEmpty(b.Title) && b.Title.Equals(_lockedBookTitle, StringComparison.OrdinalIgnoreCase)));
|
||||
if (isOwned)
|
||||
{
|
||||
_hasPaywall = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_hasPaywall = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetCleanText()
|
||||
{
|
||||
if (Message == null) return string.Empty;
|
||||
if (PaywallParser.TryParsePaywallTrigger(Message.Text, out var cleanText, out _, out _, out _, out _))
|
||||
{
|
||||
return cleanText;
|
||||
}
|
||||
return Message.Text;
|
||||
}
|
||||
|
||||
private string GetBubbleClass()
|
||||
{
|
||||
if (Message.Sender == "User") return "user-bubble";
|
||||
return _hasPaywall ? "ai-bubble paywalled-bubble" : "ai-bubble";
|
||||
}
|
||||
|
||||
private async Task HandlePurchase()
|
||||
{
|
||||
if (_isSimulatingPayment) return;
|
||||
|
||||
_isSimulatingPayment = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Simulate payment gateway delay (1.5 seconds)
|
||||
await Task.Delay(1500);
|
||||
|
||||
try
|
||||
{
|
||||
var bookTitle = string.IsNullOrEmpty(_lockedBookTitle)
|
||||
? "Architektura .NET 10 i Ekosystem Blazor"
|
||||
: _lockedBookTitle;
|
||||
|
||||
// Call POST endpoint to persist the purchase
|
||||
var response = await Http.PostAsJsonAsync("api/library/purchase", new { Title = bookTitle });
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_isUnlocked = true;
|
||||
_hasPaywall = false;
|
||||
_showSuccessBanner = true;
|
||||
|
||||
// Fetch updated library list and update state manager
|
||||
var updatedBooks = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
|
||||
LibraryStateService.OwnedBooks = updatedBooks;
|
||||
|
||||
if (OnUnlockRequested.HasDelegate)
|
||||
{
|
||||
await OnUnlockRequested.InvokeAsync(_lockedBookId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("[AiResponseRenderer] Purchase failed on server for book {BookId}.", _lockedBookId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[AiResponseRenderer] Error processing purchase for book {BookId}.", _lockedBookId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSimulatingPayment = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private List<ResponseSegment> ParseSegments(string text)
|
||||
{
|
||||
var segments = new List<ResponseSegment>();
|
||||
if (string.IsNullOrEmpty(text)) return segments;
|
||||
|
||||
var regex = new System.Text.RegularExpressions.Regex(
|
||||
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
var matches = regex.Matches(text);
|
||||
|
||||
int lastIndex = 0;
|
||||
foreach (System.Text.RegularExpressions.Match match in matches)
|
||||
{
|
||||
if (match.Index > lastIndex)
|
||||
{
|
||||
segments.Add(new ResponseSegment
|
||||
{
|
||||
Text = text.Substring(lastIndex, match.Index - lastIndex),
|
||||
IsCitation = false
|
||||
});
|
||||
}
|
||||
|
||||
var citationId = match.Groups[1].Success
|
||||
? match.Groups[1].Value.Trim()
|
||||
: match.Groups[2].Value.Trim();
|
||||
|
||||
segments.Add(new ResponseSegment
|
||||
{
|
||||
IsCitation = true,
|
||||
CitationId = citationId
|
||||
});
|
||||
|
||||
lastIndex = match.Index + match.Length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.Length)
|
||||
{
|
||||
segments.Add(new ResponseSegment
|
||||
{
|
||||
Text = text.Substring(lastIndex),
|
||||
IsCitation = false
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private MarkupString RenderMarkdown(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
|
||||
|
||||
var html = System.Net.WebUtility.HtmlEncode(text);
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-code-block\"><code>$1</code></pre>");
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "<code class=\"nexus-inline-code\">$1</code>");
|
||||
html = html.Replace("\n", "<br />");
|
||||
|
||||
return new MarkupString(html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 90%;
|
||||
margin-bottom: 1.5rem;
|
||||
animation: bubble-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
align-self: flex-end;
|
||||
margin-left: auto;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.ai-row {
|
||||
align-self: flex-start;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-row .message-avatar {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 0 10px var(--border);
|
||||
}
|
||||
|
||||
.ai-row .message-avatar {
|
||||
background: linear-gradient(135deg, #005f38 0%, #004024 100%);
|
||||
color: #e6fffa;
|
||||
border: 1px solid var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
font-size: 0.975rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-main);
|
||||
border-top-right-radius: 4px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.ai-bubble {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-main);
|
||||
border-top-left-radius: 4px;
|
||||
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.paywalled-bubble {
|
||||
border-color: var(--accent-glow);
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Paragraph spacing */
|
||||
.message-content p {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.message-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Paywall Blur Styles */
|
||||
.paywall-teaser {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Upsell Card */
|
||||
.upsell-card {
|
||||
background: var(--bg-base);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--accent-glow);
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
box-shadow: 0 8px 32px var(--accent-glow), 0 4px 12px var(--border);
|
||||
animation: card-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.upsell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.upsell-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.upsell-header h4 {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.upsell-text {
|
||||
color: var(--text-main);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 1.25rem 0;
|
||||
}
|
||||
|
||||
.upsell-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-upsell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.5px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: var(--bg-surface);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--accent-glow);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--accent-glow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Success Banner */
|
||||
.success-unlock-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--accent-glow);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
animation: fade-in 0.5s ease-out;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Payment Spinner */
|
||||
.payment-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
margin-right: 0.75rem;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes bubble-fade-in {
|
||||
0% { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes card-slide-in {
|
||||
0% { opacity: 0; transform: translateY(10px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||
============================================================ */
|
||||
|
||||
.theme-light .ai-row .message-avatar {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.theme-light .user-row .message-avatar {
|
||||
box-shadow: 0 2px 8px rgba(139, 130, 115, 0.1);
|
||||
}
|
||||
|
||||
.theme-light .upsell-card {
|
||||
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.theme-light .btn-primary {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-light .btn-primary:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
color: #ffffff;
|
||||
opacity: 1;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.theme-light .btn-secondary {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-light .btn-secondary:hover {
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.theme-light .paywall-teaser {
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Linq
|
||||
@inject IFocusModeService FocusMode
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IThemeService ThemeService
|
||||
@inject IKnowledgeService KnowledgeService
|
||||
@inject ILogger<IntelligenceToolbar> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<aside class="intelligence-toolbar">
|
||||
@@ -46,26 +49,30 @@
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged += HandleUpdate;
|
||||
ThemeService.OnThemeChanged += HandleThemeChangedAsync;
|
||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||
}
|
||||
|
||||
private async Task HandleClearCache()
|
||||
{
|
||||
Console.WriteLine("[IntelligenceToolbar] Requesting cache clear...");
|
||||
Logger.LogInformation("[IntelligenceToolbar] Requesting cache clear...");
|
||||
var result = await KnowledgeService.ClearCacheAsync();
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Console.WriteLine("[IntelligenceToolbar] Cache cleared successfully!");
|
||||
Logger.LogInformation("[IntelligenceToolbar] Cache cleared successfully.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("[IntelligenceToolbar] Cache clear failed: {Errors}", string.Join("; ", result.Errors.Select(e => e.Message)));
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private Task HandleThemeChangedAsync() => InvokeAsync(StateHasChanged);
|
||||
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged -= HandleUpdate;
|
||||
ThemeService.OnThemeChanged -= HandleThemeChangedAsync;
|
||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.UI.Shared.Models
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@inject IReaderInteractionService InteractionService
|
||||
@inject IQuizStateService QuizService
|
||||
@inject IJSRuntime JS
|
||||
@inject ILogger<SelectionAiPanel> Logger
|
||||
|
||||
@if (IsVisible)
|
||||
{
|
||||
@@ -64,7 +66,7 @@
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}");
|
||||
Logger.LogDebug("[SelectionAiPanel] Parameters set. SelectedText: {Length} chars, Coordinates: {Top}", SelectedText.Length, Coordinates?.Top);
|
||||
|
||||
if (Coordinates != _lastCoordinates)
|
||||
{
|
||||
@@ -100,7 +102,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Error positioning toolbar: {ex.Message}");
|
||||
Logger.LogWarning(ex, "[SelectionAiPanel] Error positioning toolbar.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +135,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Error requesting summary: {ex.Message}");
|
||||
Logger.LogError(ex, "[SelectionAiPanel] Error requesting summary for block {BlockId}.", BlockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -173,7 +175,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Error generating quiz: {ex.Message}");
|
||||
Logger.LogError(ex, "[SelectionAiPanel] Error generating quiz for block {BlockId}.", BlockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -233,3 +233,126 @@
|
||||
.lock-icon {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||
============================================================ */
|
||||
|
||||
.theme-light .concepts-map::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-light .empty-map-state {
|
||||
background: rgba(0, 0, 0, 0.01);
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .empty-map-state .dim-icon {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.theme-light .timeline-step:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.theme-light .timeline-step.unlocked:hover {
|
||||
border-color: rgba(16, 185, 129, 0.15);
|
||||
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.theme-light .timeline-step.selected {
|
||||
background: rgba(16, 185, 129, 0.04);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.theme-light .node-circle {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.theme-light .unlocked .node-circle {
|
||||
background: var(--bg-surface);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-light .locked .node-circle {
|
||||
background: var(--bg-base);
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .node-glow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-light .track-active {
|
||||
background: var(--accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-light .track-inactive {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.theme-light .node-content {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.theme-light .timeline-step.selected .node-content {
|
||||
background: var(--bg-surface);
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.theme-light .segment-tag {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .unlocked .segment-tag {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-light .badge-unlocked {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
color: var(--accent);
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.theme-light .badge-locked {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.theme-light .node-title {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-light .timeline-step.unlocked:hover .node-title {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-light .locked .node-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .node-desc {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .locked .node-desc {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .check-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-light .lock-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject IRecommendationService RecommendationService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<ContextualRecommendationsWidget> Logger
|
||||
|
||||
<section class="recommendations-panel glass-panel" aria-label="Kontekstowe rekomendacje">
|
||||
<div class="panel-header">
|
||||
<div class="header-left">
|
||||
<NexusIcon Name="sparkles" Size="18" />
|
||||
<h4>Odkryj Więcej</h4>
|
||||
</div>
|
||||
<span class="panel-badge">AI</span>
|
||||
</div>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div @key='"loading"' class="loading-state" role="status" aria-label="Ładowanie rekomendacji">
|
||||
<div class="spinner-ring">
|
||||
<div class="spinner-track"></div>
|
||||
<div class="spinner-head"></div>
|
||||
</div>
|
||||
<span class="loading-label">Analizowanie kontekstu lektury…</span>
|
||||
</div>
|
||||
}
|
||||
else if (_hasError)
|
||||
{
|
||||
<div @key='"error"' class="empty-state">
|
||||
<NexusIcon Name="alert-circle" Size="32" />
|
||||
<p>Nie udało się załadować rekomendacji.</p>
|
||||
</div>
|
||||
}
|
||||
else if (_recommendations is null || _recommendations.Count == 0)
|
||||
{
|
||||
<div @key='"empty"' class="empty-state">
|
||||
<NexusIcon Name="book-open" Size="32" />
|
||||
<p>Zacznij czytać, aby odkryć powiązane tytuły.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul @key='"list"' class="recommendations-list" role="list">
|
||||
@foreach (var rec in _recommendations)
|
||||
{
|
||||
<li @key="rec.TargetBookId" class="recommendation-item @(rec.IsPremiumUpsell ? "premium" : "owned")"
|
||||
role="listitem">
|
||||
<div class="rec-content">
|
||||
<div class="rec-meta">
|
||||
<span class="match-badge" title="Dopasowanie semantyczne @rec.MatchPercentage%">
|
||||
@rec.MatchPercentage<span class="match-unit">%</span>
|
||||
</span>
|
||||
@if (rec.IsPremiumUpsell)
|
||||
{
|
||||
<span class="upsell-tag" aria-label="Książka premium">Premium</span>
|
||||
}
|
||||
</div>
|
||||
<p class="rec-book-title">@rec.BookTitle</p>
|
||||
<p class="rec-chapter-title">@rec.ChapterTitle</p>
|
||||
</div>
|
||||
<button class="rec-action-btn"
|
||||
@onclick="() => HandleRecommendationClick(rec)"
|
||||
aria-label="@(rec.IsPremiumUpsell ? "Kup " + rec.BookTitle : "Przejdź do " + rec.BookTitle)">
|
||||
<NexusIcon Name="@(rec.IsPremiumUpsell ? "shopping-cart" : "arrow-right")" Size="16" />
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private List<RecommendationDto>? _recommendations;
|
||||
private bool _isLoading = true;
|
||||
private bool _hasError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadRecommendationsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadRecommendationsAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
_hasError = false;
|
||||
|
||||
try
|
||||
{
|
||||
_recommendations = await RecommendationService.GetRecommendationsAsync();
|
||||
if (_recommendations is null)
|
||||
{
|
||||
_hasError = true;
|
||||
Logger.LogWarning("[ContextualRecommendationsWidget] RecommendationService returned null; displaying error state.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRecommendationClick(RecommendationDto rec)
|
||||
{
|
||||
if (rec.IsPremiumUpsell)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/catalog?highlight={rec.TargetBookId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
NavigationManager.NavigateTo($"/reader/{rec.TargetBookId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
/* ContextualRecommendationsWidget.razor.css
|
||||
Uses Nexus Design System tokens (--nexus-*) for consistency.
|
||||
*/
|
||||
|
||||
.recommendations-panel {
|
||||
width: 100%;
|
||||
padding: 1.75rem;
|
||||
background: var(--nexus-surface, #1a1a1e);
|
||||
border: 1px solid var(--nexus-border, rgba(255, 255, 255, 0.05));
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.recommendations-panel:hover {
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* ── Panel Header ── */
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--nexus-accent, #10b981);
|
||||
}
|
||||
|
||||
.header-left h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--nexus-text-primary, #ffffff);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.panel-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 100px;
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(59, 130, 246, 0.1));
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
color: var(--nexus-accent, #10b981);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Loading State ── */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.spinner-ring {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.spinner-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.spinner-head {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid transparent;
|
||||
border-top-color: var(--nexus-accent, #10b981);
|
||||
animation: nexus-spin 0.8s linear infinite;
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
@keyframes nexus-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Empty / Error State ── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ── Recommendations List ── */
|
||||
.recommendations-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.recommendation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.recommendation-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.recommendation-item.premium {
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.recommendation-item.premium:hover {
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
background: rgba(245, 158, 11, 0.04);
|
||||
}
|
||||
|
||||
.recommendation-item.owned {
|
||||
border-color: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.recommendation-item.owned:hover {
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
/* ── Rec Content ── */
|
||||
.rec-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.rec-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.match-badge {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--nexus-accent, #10b981);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
|
||||
.match-unit {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upsell-tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rec-book-title {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--nexus-text-primary, #ffffff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rec-chapter-title {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Action Button ── */
|
||||
.rec-action-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.rec-action-btn:hover {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: var(--nexus-accent, #10b981);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.premium .rec-action-btn:hover {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.recommendations-panel {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||
============================================================ */
|
||||
|
||||
.theme-light .recommendations-panel {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.theme-light .recommendations-panel:hover {
|
||||
box-shadow: 0 8px 24px rgba(139, 130, 115, 0.12);
|
||||
}
|
||||
|
||||
.theme-light .header-left h4 {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-light .spinner-track {
|
||||
border: 3px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.theme-light .loading-label,
|
||||
.theme-light .empty-state {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .recommendation-item {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.theme-light .recommendation-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.theme-light .recommendation-item.premium {
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.theme-light .recommendation-item.premium:hover {
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
background: rgba(245, 158, 11, 0.04);
|
||||
}
|
||||
|
||||
.theme-light .recommendation-item.owned {
|
||||
border-color: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.theme-light .recommendation-item.owned:hover {
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.theme-light .rec-book-title {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-light .rec-chapter-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .rec-action-btn {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .rec-action-btn:hover {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-light .premium .rec-action-btn:hover {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
color: #f59e0b;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<section class="current-reading-card glass-panel">
|
||||
@if (Book != null)
|
||||
{
|
||||
<div class="card-layout">
|
||||
<div @key='"current-reading-book"' class="card-layout">
|
||||
<div class="book-cover">
|
||||
<img src="@(Book.CoverUrl ?? "https://via.placeholder.com/120x180?text=No+Cover")" alt="@Book.Title" />
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="empty-state">
|
||||
<div @key='"current-reading-empty"' class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<NexusIcon Name="book-open" Size="48" />
|
||||
</div>
|
||||
|
||||
@@ -210,3 +210,60 @@
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||
============================================================ */
|
||||
|
||||
.theme-light .current-reading-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.theme-light .current-reading-card:hover {
|
||||
background: var(--bg-surface);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 10px 30px rgba(139, 130, 115, 0.12);
|
||||
}
|
||||
|
||||
.theme-light .book-cover img {
|
||||
box-shadow: 0 15px 35px rgba(139, 130, 115, 0.18);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.theme-light .book-title {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-light .author-name {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .chapter-name {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-light .progress-bar-container {
|
||||
background: #e4e1d9;
|
||||
}
|
||||
|
||||
.theme-light .progress-bar-fill {
|
||||
box-shadow: 0 0 6px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.theme-light .book-excerpt {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .empty-text h3 {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-light .empty-text p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .empty-icon {
|
||||
color: var(--accent);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||
}
|
||||
|
||||
private Task HandleThemeChanged() => InvokeAsync(StateHasChanged);
|
||||
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||
|
||||
private double GetDashOffset()
|
||||
{
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Coordinator.ClearAsync();
|
||||
ThemeService.OnThemeChanged += HandleUpdate;
|
||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
||||
QuizService.OnQuizUpdated += HandleUpdate;
|
||||
|
||||
@@ -451,6 +451,8 @@
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void HandleEscape()
|
||||
{
|
||||
if (ViewModel != null)
|
||||
@@ -466,7 +468,7 @@
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
ThemeService.OnThemeChanged -= HandleUpdate;
|
||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
||||
QuizService.OnQuizUpdated -= HandleUpdate;
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
|
||||
@if (!_isFullyLoaded)
|
||||
{
|
||||
<div class="app-preloader" style="backdrop-filter: blur(15px); background: rgba(18, 18, 18, 0.95); z-index: 100000; color: #ffffff;">
|
||||
<div @key='"preloader"' class="app-preloader" style="backdrop-filter: blur(15px); background: rgba(18, 18, 18, 0.95); z-index: 100000; color: #ffffff;">
|
||||
<div class="preloader-spinner"></div>
|
||||
<div class="preloader-text" style="color: #ffffff;">Synchronizing Secure Session...</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="hub-container @(_isMobileMenuOpen ? "mobile-menu-open" : "")">
|
||||
<div @key='"hub-container"' class="hub-container @(_isMobileMenuOpen ? "mobile-menu-open" : "")">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<!-- Mobile Sticky Top-bar -->
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #121214;
|
||||
color: #e4e4e7;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-main);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::deep .hub-sidebar {
|
||||
width: 80px;
|
||||
height: 100%;
|
||||
background: #0d0d0d;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
@@ -55,7 +55,7 @@
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
color: #8b8273;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
position: relative;
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
::deep .nav-item:hover {
|
||||
color: #10b981;
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
::deep .nav-item:focus-visible {
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
::deep .sidebar-footer {
|
||||
padding: 1.5rem 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -119,15 +119,15 @@
|
||||
::deep .user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #e4e4e7;
|
||||
color: var(--text-main);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
::deep .logout-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #8b8273;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
@@ -157,7 +157,7 @@
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #121214;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.hub-content {
|
||||
@@ -204,10 +204,10 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background: rgba(18, 18, 18, 0.85);
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 1.25rem;
|
||||
z-index: 150;
|
||||
}
|
||||
@@ -295,7 +295,7 @@
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
background: #141414;
|
||||
background: var(--bg-surface);
|
||||
z-index: 200;
|
||||
transform: translateX(-100%);
|
||||
will-change: transform;
|
||||
@@ -324,7 +324,7 @@
|
||||
|
||||
::deep .sidebar-header {
|
||||
padding: 1.5rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
::deep .sidebar-nav {
|
||||
@@ -342,4 +342,80 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||
Scoped via .theme-light on an ancestor element.
|
||||
============================================================ */
|
||||
|
||||
/* --- Desktop Sidebar: warm paper shadow --- */
|
||||
.theme-light ::deep .hub-sidebar {
|
||||
box-shadow: 4px 0 20px rgba(139, 130, 115, 0.08);
|
||||
}
|
||||
|
||||
/* --- Logo icon: remove neon glow --- */
|
||||
.theme-light ::deep .logo-icon {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* --- Nav item hover: ensure green text, warm hover bg --- */
|
||||
.theme-light ::deep .nav-item:hover {
|
||||
color: #10b981;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* --- Nav active indicator: reduced glow --- */
|
||||
.theme-light ::deep .nav-item.active::before {
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
/* --- Nexus loader: remove neon drop-shadow --- */
|
||||
.theme-light ::deep .nexus-loader {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* --- Mobile Styles --- */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
/* Hamburger button: dark text on warm paper */
|
||||
.theme-light .hamburger-btn {
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.theme-light .hamburger-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* User avatar mini: solid accent, white text, no neon glow */
|
||||
.theme-light .user-avatar-mini {
|
||||
background: #10b981;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
/* Pulsing logo: subtle accent pulse, no neon glow */
|
||||
.theme-light .pulsing-logo {
|
||||
animation: pulse-glow-light 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow-light {
|
||||
0%, 100% {
|
||||
filter: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 4px rgba(16, 185, 129, 0.2));
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile sidebar open state: warm shadow instead of dark */
|
||||
.theme-light .mobile-menu-open ::deep .hub-sidebar {
|
||||
box-shadow: 10px 0 30px rgba(139, 130, 115, 0.2);
|
||||
}
|
||||
|
||||
/* Mobile topbar: warm paper border */
|
||||
.theme-light .nexus-mobile-topbar {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@
|
||||
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
|
||||
InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged;
|
||||
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
|
||||
ThemeService.OnThemeChanged += HandleThemeChangedAsync;
|
||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||
Coordinator.OnSelectionSummaryStateChanged += HandleUpdate;
|
||||
|
||||
var context = PlatformService.GetDeviceContext();
|
||||
@@ -359,7 +359,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleThemeChangedAsync() => await InvokeAsync(StateHasChanged);
|
||||
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void SetActiveTab(SidebarTab tab)
|
||||
{
|
||||
@@ -520,7 +520,7 @@
|
||||
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
|
||||
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
|
||||
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
|
||||
ThemeService.OnThemeChanged -= HandleThemeChangedAsync;
|
||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||
Coordinator.OnSelectionSummaryStateChanged -= HandleUpdate;
|
||||
|
||||
try
|
||||
|
||||
@@ -28,6 +28,12 @@ public class ChatMessage
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
public List<ResponseSegment> Segments { get; set; } = new();
|
||||
public List<CitationDto> Citations { get; set; } = new();
|
||||
|
||||
public string ClearText { get; set; } = string.Empty;
|
||||
public string BlurredTeaserText { get; set; } = string.Empty;
|
||||
public bool IsPaywalled { get; set; }
|
||||
public string SourceBookTitle { get; set; } = string.Empty;
|
||||
public string DocumentId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<span>lub</span>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form">
|
||||
<EditForm FormName="login-form" Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="field-group">
|
||||
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
|
||||
<form @formname="hidden-login-form" id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
|
||||
<input type="hidden" name="email" value="@_loginModel.Email" />
|
||||
<input type="hidden" name="password" value="@_loginModel.Password" />
|
||||
<input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" />
|
||||
@@ -117,7 +117,10 @@
|
||||
[SupplyParameterFromQuery(Name = "returnUrl")]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
private LoginModel _loginModel = new();
|
||||
#pragma warning disable BL0008
|
||||
[SupplyParameterFromForm(FormName = "login-form")]
|
||||
private LoginModel _loginModel { get; set; } = new();
|
||||
#pragma warning restore BL0008
|
||||
private string? _errorMessage;
|
||||
private bool _isSubmitting;
|
||||
private bool _showPassword;
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@attribute [Authorize]
|
||||
@using NexusReader.Domain.Enums
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IThemeService ThemeService
|
||||
|
||||
<div class="profile-page-container">
|
||||
<div class="background-radial"></div>
|
||||
@@ -97,6 +99,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Preferences Card -->
|
||||
<div class="metric-card glass-panel full-width theme-preference-card">
|
||||
<div class="card-header">
|
||||
<NexusIcon Name="settings" Size="24" Color="#10b981" />
|
||||
<h3>Preferencje Wizualne</h3>
|
||||
</div>
|
||||
<div class="card-body theme-selector-layout">
|
||||
<p class="theme-description">Wybierz profil wizualny systemu zoptymalizowany dla Twojego urządzenia i warunków czytania.</p>
|
||||
<div class="theme-options">
|
||||
<button class="theme-option-btn @(ThemeService.Mode == ThemeMode.System ? "active" : "")" @onclick="() => ChangeTheme(ThemeMode.System)">
|
||||
<NexusIcon Name="cpu" Size="16" />
|
||||
<span>Systemowy</span>
|
||||
</button>
|
||||
<button class="theme-option-btn @(ThemeService.Mode == ThemeMode.Dark ? "active" : "")" @onclick="() => ChangeTheme(ThemeMode.Dark)">
|
||||
<NexusIcon Name="moon" Size="16" />
|
||||
<span>Modern Deep Dark</span>
|
||||
</button>
|
||||
<button class="theme-option-btn @(ThemeService.Mode == ThemeMode.LightSepia ? "active" : "")" @onclick="() => ChangeTheme(ThemeMode.LightSepia)">
|
||||
<NexusIcon Name="sun" Size="16" />
|
||||
<span>Warm Sepia</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -110,6 +137,7 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await ThemeService.InitializeAsync();
|
||||
var result = await IdentityService.GetProfileAsync();
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
@@ -118,6 +146,12 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ChangeTheme(ThemeMode mode)
|
||||
{
|
||||
await ThemeService.SetThemeAsync(mode);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private int CalculateProgress()
|
||||
{
|
||||
if (_profile == null || _profile.AITokenLimit == 0) return 0;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background-color: #121214;
|
||||
color: #e4e4e7;
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-main);
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -26,7 +26,7 @@
|
||||
.mesh-overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background-image: radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.02) 1px, transparent 0);
|
||||
background-image: radial-gradient(circle at 1px 1px, var(--border) 1px, transparent 0);
|
||||
background-size: 32px 32px;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
.avatar-inner {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: #1a1a1e;
|
||||
background: var(--bg-surface);
|
||||
border: 2px solid #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
@@ -98,7 +98,7 @@
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.system-rank {
|
||||
@@ -120,17 +120,17 @@
|
||||
|
||||
.glass-panel {
|
||||
padding: 32px;
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-4px);
|
||||
background: #1e1e24;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
@@ -154,7 +154,7 @@
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #a0aec0;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -177,14 +177,14 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-values .current { font-size: 2.5rem; font-weight: 800; color: #fff; line-height: 1; }
|
||||
.usage-values .separator { font-size: 1.2rem; color: #4a5568; }
|
||||
.usage-values .total { font-size: 1.2rem; color: #718096; font-weight: 600; }
|
||||
.usage-values .current { font-size: 2.5rem; font-weight: 800; color: var(--text-main); line-height: 1; }
|
||||
.usage-values .separator { font-size: 1.2rem; color: var(--border); }
|
||||
.usage-values .total { font-size: 1.2rem; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
.usage-progress {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -198,7 +198,7 @@
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -218,7 +218,7 @@
|
||||
|
||||
.score-label {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -229,11 +229,11 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e0;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.truncate {
|
||||
@@ -273,9 +273,9 @@
|
||||
}
|
||||
|
||||
.plan-badge.free {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #a1a1aa;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tenant-tag {
|
||||
@@ -348,3 +348,111 @@
|
||||
.btn-nexus { width: 100%; justify-content: center; }
|
||||
.username { font-size: 2.2rem; }
|
||||
}
|
||||
|
||||
/* Theme Preference Card Styles */
|
||||
.theme-preference-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.theme-description {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.theme-options {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.theme-option-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-option-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-option-btn.active {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.theme-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Light Theme Overrides — Warm Paper / Soft Sepia
|
||||
============================================ */
|
||||
|
||||
/* Background radial — warmer, slightly stronger glow */
|
||||
.theme-light .background-radial {
|
||||
background: radial-gradient(circle, rgba(16, 185, 129, 0.04) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* Avatar — keep green accent, reduce glow intensity */
|
||||
.theme-light .avatar-inner {
|
||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.12), inset 0 0 15px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
/* Avatar glow ring — softer border */
|
||||
.theme-light .avatar-glow {
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* Glass panel hover — warm sepia shadow instead of pure black */
|
||||
.theme-light .glass-panel:hover {
|
||||
box-shadow: 0 10px 30px rgba(139, 130, 115, 0.12);
|
||||
}
|
||||
|
||||
/* Progress bar — reduce neon glow */
|
||||
.theme-light .progress-bar {
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* Decorative text — dark ink on light bg instead of light on dark */
|
||||
.theme-light .decoration {
|
||||
color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Tenant tag — warm stone gray */
|
||||
.theme-light .tenant-tag {
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
/* Loader — disable neon drop-shadow, softer border */
|
||||
.theme-light .nexus-loader {
|
||||
border-color: rgba(16, 185, 129, 0.15);
|
||||
border-top-color: #10b981;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Theme option active — reduce glow in light mode */
|
||||
.theme-light .theme-option-btn.active {
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
/* Progress bar track — light stone gray */
|
||||
.theme-light .usage-progress {
|
||||
background: #e4e1d9;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<p class="auth-subtitle">Utwórz nowe konto</p>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form">
|
||||
<EditForm FormName="register-form" Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="field-group">
|
||||
@@ -71,14 +71,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
|
||||
<form @formname="hidden-register-login-form" id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
|
||||
<input type="hidden" name="email" value="@_registerModel.Email" />
|
||||
<input type="hidden" name="password" value="@_registerModel.Password" />
|
||||
<input type="hidden" name="rememberMe" value="false" />
|
||||
</form>
|
||||
|
||||
@code {
|
||||
private RegisterModel _registerModel = new();
|
||||
#pragma warning disable BL0008
|
||||
[SupplyParameterFromForm(FormName = "register-form")]
|
||||
private RegisterModel _registerModel { get; set; } = new();
|
||||
#pragma warning restore BL0008
|
||||
private string? _errorMessage;
|
||||
private bool _isSubmitting;
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
@page "/catalog"
|
||||
@attribute [Authorize]
|
||||
@implements IDisposable
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Net.Http.Json
|
||||
@inject HttpClient Http
|
||||
@inject IReaderNavigationService ReaderNavigation
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILibraryStateService LibraryStateService
|
||||
@inject ILogger<Catalog> Logger
|
||||
|
||||
<div class="catalog-page">
|
||||
<header class="catalog-header">
|
||||
@@ -189,6 +193,11 @@
|
||||
private bool _isLoading = true;
|
||||
private List<LastReadBookDto>? _books;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged += HandleBooksChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
@@ -197,6 +206,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleBooksChanged()
|
||||
{
|
||||
_ = InvokeAsync(LoadBooksAsync);
|
||||
}
|
||||
|
||||
private async Task LoadBooksAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
@@ -209,7 +223,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Catalog] Failed to load books: {ex.Message}");
|
||||
Logger.LogError(ex, "[Catalog] Failed to load books.");
|
||||
if (OperatingSystem.IsBrowser())
|
||||
{
|
||||
_isLoading = false;
|
||||
@@ -231,4 +245,9 @@
|
||||
// Showcase callback
|
||||
NavigationManager.NavigateTo("/profile");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged -= HandleBooksChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.catalog-header .subtitle {
|
||||
font-size: 1rem;
|
||||
color: #a1a1aa;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -38,27 +38,27 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.course-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card-cover-container {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card-cover {
|
||||
@@ -147,8 +147,9 @@
|
||||
align-self: flex-start;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: #a1a1aa;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
@@ -175,21 +176,21 @@
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.4rem 0;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
line-height: 1.3;
|
||||
font-family: var(--nexus-font-sans, "Outfit", sans-serif);
|
||||
}
|
||||
|
||||
.course-author {
|
||||
font-size: 0.85rem;
|
||||
color: #a1a1aa;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.course-desc {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
color: #a1a1aa;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 1.5rem 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
@@ -204,8 +205,8 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: #a1a1aa;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -256,14 +257,14 @@
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
height: 440px;
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.skeleton-cover {
|
||||
height: 200px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
|
||||
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
@@ -276,7 +277,7 @@
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
|
||||
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
@@ -314,9 +315,9 @@
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem 2.25rem;
|
||||
border-radius: 40px;
|
||||
background: rgba(13, 13, 15, 0.85);
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid var(--border);
|
||||
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@@ -332,7 +333,7 @@
|
||||
|
||||
.loader-text {
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@@ -356,3 +357,34 @@
|
||||
from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; }
|
||||
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LIGHT THEME OVERRIDES — Warm Paper / Soft Sepia
|
||||
============================================ */
|
||||
|
||||
.theme-light .course-card:hover {
|
||||
box-shadow: 0 12px 30px rgba(139, 130, 115, 0.15);
|
||||
}
|
||||
|
||||
.theme-light .card-cover-container {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.theme-light .cover-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.theme-light .course-card:hover .start-action {
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.theme-light .dotnet-gradient,
|
||||
.theme-light .blazor-gradient,
|
||||
.theme-light .graph-gradient {
|
||||
background: #e4e1d9;
|
||||
}
|
||||
|
||||
.theme-light .cover-code-text {
|
||||
color: var(--text-main);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 1.25rem 2rem;
|
||||
background: rgba(20, 20, 20, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.header-back .btn-back {
|
||||
@@ -30,22 +30,22 @@
|
||||
}
|
||||
|
||||
.header-back .btn-back:hover {
|
||||
border-color: var(--nexus-neon);
|
||||
color: var(--nexus-neon);
|
||||
background: var(--nexus-primary-glow);
|
||||
box-shadow: 0 0 10px var(--nexus-primary-glow);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-glow);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.header-title .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header-actions .btn-action {
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
|
||||
.header-actions .btn-action:hover {
|
||||
box-shadow: 0 0 20px var(--nexus-primary-glow);
|
||||
box-shadow: 0 0 20px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* Grid Layout */
|
||||
@@ -73,28 +73,26 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl, 16px);
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pane-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pane-content {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Loading, Error and Empty States */
|
||||
.loading-state, .error-state, .empty-dashboard-state {
|
||||
display: flex;
|
||||
@@ -118,15 +116,15 @@
|
||||
}
|
||||
|
||||
.neon-pulse {
|
||||
color: var(--nexus-neon);
|
||||
filter: drop-shadow(0 0 10px var(--nexus-neon));
|
||||
color: var(--accent);
|
||||
filter: drop-shadow(0 0 10px var(--accent-glow));
|
||||
animation: robot-pulse 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes robot-pulse {
|
||||
0% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); }
|
||||
50% { transform: scale(1.08); filter: drop-shadow(0 0 25px var(--nexus-neon)); }
|
||||
100% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); }
|
||||
0% { transform: scale(1); filter: drop-shadow(0 0 10px var(--accent-glow)); }
|
||||
50% { transform: scale(1.08); filter: drop-shadow(0 0 25px var(--accent-glow)); }
|
||||
100% { transform: scale(1); filter: drop-shadow(0 0 10px var(--accent-glow)); }
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
@@ -135,8 +133,8 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--nexus-neon);
|
||||
box-shadow: 0 0 15px var(--nexus-neon);
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 15px var(--accent);
|
||||
animation: scan 2s infinite linear;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -149,7 +147,7 @@
|
||||
|
||||
.loading-text {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--text-muted);
|
||||
margin-top: 1rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -164,17 +162,18 @@
|
||||
}
|
||||
|
||||
.dim-icon {
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-dashboard-state h2, .error-state h3 {
|
||||
color: #fff;
|
||||
color: var(--text-main);
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-dashboard-state p, .error-state p {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 2rem 0;
|
||||
@@ -189,25 +188,25 @@
|
||||
flex-grow: 1;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-glowing-brain {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 255, 153, 0.04);
|
||||
border: 1px solid rgba(0, 255, 153, 0.15);
|
||||
background: var(--accent-glow);
|
||||
border: 1px solid var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 0 20px var(--nexus-primary-glow);
|
||||
box-shadow: 0 0 20px var(--accent-glow);
|
||||
}
|
||||
|
||||
.workspace-empty h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #fff;
|
||||
color: var(--text-main);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -227,7 +226,7 @@
|
||||
|
||||
.workspace-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.node-meta {
|
||||
@@ -242,7 +241,7 @@
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nexus-neon);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -256,22 +255,22 @@
|
||||
}
|
||||
|
||||
.badge-unlocked {
|
||||
background: rgba(0, 255, 153, 0.08);
|
||||
color: var(--nexus-neon);
|
||||
border: 1px solid rgba(0, 255, 153, 0.2);
|
||||
background: var(--accent-glow);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.badge-locked {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.workspace-title {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.workspace-body {
|
||||
@@ -291,22 +290,22 @@
|
||||
background: transparent;
|
||||
}
|
||||
.workspace-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.workspace-body::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--nexus-neon);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.locked-warning {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
background: rgba(255, 171, 0, 0.04);
|
||||
border: 1px solid rgba(255, 171, 0, 0.15);
|
||||
background: rgba(217, 119, 6, 0.05);
|
||||
border: 1px solid rgba(217, 119, 6, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.lock-warning-icon {
|
||||
@@ -326,14 +325,14 @@
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.metadata-section h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
@@ -345,12 +344,12 @@
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-left: 3px solid var(--nexus-neon);
|
||||
background: var(--bg-base);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
@@ -365,9 +364,9 @@
|
||||
|
||||
.term-pill {
|
||||
font-size: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
@@ -375,14 +374,14 @@
|
||||
}
|
||||
|
||||
.term-pill:hover {
|
||||
border-color: rgba(0, 255, 153, 0.2);
|
||||
color: var(--nexus-neon);
|
||||
background: rgba(0, 255, 153, 0.03);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-glow);
|
||||
}
|
||||
|
||||
.workspace-footer {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -403,3 +402,54 @@
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Light Theme Overrides — Warm Paper / Soft Sepia
|
||||
============================================ */
|
||||
|
||||
/* Dashboard header — warm sepia shadow instead of pure black */
|
||||
.theme-light .dashboard-header {
|
||||
box-shadow: 0 4px 30px rgba(139, 130, 115, 0.05);
|
||||
}
|
||||
|
||||
/* Neon pulse icon — disable glow filter entirely */
|
||||
.theme-light .neon-pulse {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Override the neon pulse keyframe states in light mode */
|
||||
.theme-light .neon-pulse {
|
||||
animation-name: robot-pulse-light;
|
||||
}
|
||||
|
||||
@keyframes robot-pulse-light {
|
||||
0% { transform: scale(1); filter: none; }
|
||||
50% { transform: scale(1.08); filter: none; }
|
||||
100% { transform: scale(1); filter: none; }
|
||||
}
|
||||
|
||||
/* Scan line — reduce glow intensity */
|
||||
.theme-light .scan-line {
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Glowing brain empty state — subtle warm glow */
|
||||
.theme-light .empty-glowing-brain {
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
/* Error icon — reduce drop-shadow intensity */
|
||||
.theme-light .error-icon {
|
||||
filter: drop-shadow(0 0 4px rgba(255, 74, 74, 0.2));
|
||||
}
|
||||
|
||||
/* Back button hover — warm glow instead of neon */
|
||||
.theme-light .header-back .btn-back:hover {
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
/* Action button hover — warm glow */
|
||||
.theme-light .header-actions .btn-action:hover {
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
@@ -61,24 +61,28 @@
|
||||
|
||||
@if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any())
|
||||
{
|
||||
@for (int i = 0; i < _profile.MappedConcepts.Count; i++)
|
||||
{
|
||||
var concept = _profile.MappedConcepts[i];
|
||||
var angle = i * (360.0 / _profile.MappedConcepts.Count);
|
||||
var dist = 65;
|
||||
<div class="graph-node satellite"
|
||||
style="--angle: @(angle)deg; --dist: @(dist)px;"
|
||||
title="[@concept.Type] @concept.Content"
|
||||
@onmouseover="() => SetHoveredConcept(concept)"
|
||||
@onmouseout="ClearHoveredConcept">
|
||||
</div>
|
||||
}
|
||||
<div @key='"satellite-concepts-container"' style="display: contents;">
|
||||
@for (int i = 0; i < _profile.MappedConcepts.Count; i++)
|
||||
{
|
||||
var concept = _profile.MappedConcepts[i];
|
||||
var angle = i * (360.0 / _profile.MappedConcepts.Count);
|
||||
var dist = 65;
|
||||
<div @key="concept.Id" class="graph-node satellite"
|
||||
style="--angle: @(angle)deg; --dist: @(dist)px;"
|
||||
title="[@concept.Type] @concept.Content"
|
||||
@onmouseover="() => SetHoveredConcept(concept)"
|
||||
@onmouseout="ClearHoveredConcept">
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div>
|
||||
<div class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div>
|
||||
<div class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div>
|
||||
<div @key='"satellite-placeholders-container"' style="display: contents;">
|
||||
<div @key='"satellite-placeholder-0"' class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div>
|
||||
<div @key='"satellite-placeholder-1"' class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div>
|
||||
<div @key='"satellite-placeholder-2"' class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="active-node-label">
|
||||
@@ -111,10 +115,10 @@
|
||||
<div class="quiz-preview">
|
||||
@if (_profile?.RecentQuizzes != null && _profile.RecentQuizzes.Any())
|
||||
{
|
||||
<div class="quiz-history-list">
|
||||
<div @key='"quiz-history-list"' class="quiz-history-list">
|
||||
@foreach (var quiz in _profile.RecentQuizzes)
|
||||
{
|
||||
<div class="quiz-history-item">
|
||||
<div @key="quiz.Id" class="quiz-history-item">
|
||||
<div class="quiz-item-header">
|
||||
<span class="quiz-topic">@quiz.Topic</span>
|
||||
<span class="quiz-score badge @(quiz.Percentage >= 80 ? "badge-success" : quiz.Percentage >= 50 ? "badge-warning" : "badge-danger")">
|
||||
@@ -130,7 +134,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="empty-quiz-state">
|
||||
<div @key='"empty-quiz-state"' class="empty-quiz-state">
|
||||
<p class="question">Brak rozwiązanych quizów</p>
|
||||
<p class="sub-text">Rozwiązuj quizy w trakcie czytania książek, aby śledzić swoje postępy.</p>
|
||||
</div>
|
||||
@@ -140,6 +144,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contextual AI Recommendations -->
|
||||
<ContextualRecommendationsWidget />
|
||||
|
||||
<!-- Detailed Content Block Showcase -->
|
||||
<section class="architecture-guide-panel glass-panel">
|
||||
<div class="panel-header">
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #0d0d0d;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header-grid-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||||
linear-gradient(var(--border) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--border) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
background-position: center;
|
||||
mask-image: radial-gradient(circle at center, black, transparent 80%);
|
||||
@@ -52,10 +52,10 @@
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #1a1a1a;
|
||||
border: 3px solid var(--border);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: #222;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.avatar-glow {
|
||||
@@ -78,7 +78,7 @@
|
||||
font-family: var(--nexus-font-sans);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
letter-spacing: 1px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
@@ -103,17 +103,17 @@
|
||||
|
||||
.status-pill {
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 100px;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.1);
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.pill-label { color: #A0A0A0; }
|
||||
.pill-value { color: #ffffff; font-weight: 600; }
|
||||
.pill-label { color: var(--text-muted); }
|
||||
.pill-value { color: var(--text-main); font-weight: 600; }
|
||||
|
||||
/* --- Dashboard Content --- */
|
||||
.dashboard-content {
|
||||
@@ -127,7 +127,7 @@
|
||||
font-family: var(--nexus-font-serif);
|
||||
font-size: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.main-grid {
|
||||
@@ -137,18 +137,18 @@
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
background: #1e1e24;
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
background: var(--bg-surface);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Reading Card */
|
||||
@@ -161,7 +161,7 @@
|
||||
.reading-card h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #E0E0E0;
|
||||
color: var(--text-main);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
.reading-thumb img {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.reading-info {
|
||||
@@ -196,12 +196,12 @@
|
||||
|
||||
.chapter-label {
|
||||
font-size: 0.85rem;
|
||||
color: #A0A0A0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
@@ -228,13 +228,13 @@
|
||||
|
||||
.progress-detail {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reading-desc {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #E0E0E0;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Graph Placeholder */
|
||||
@@ -325,7 +325,7 @@
|
||||
|
||||
.question {
|
||||
font-size: 0.95rem;
|
||||
color: #E0E0E0;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.quiz-options {
|
||||
@@ -336,13 +336,14 @@
|
||||
|
||||
.quiz-option {
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.quiz-option.active {
|
||||
@@ -372,9 +373,9 @@
|
||||
}
|
||||
|
||||
.btn-nexus.secondary {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-nexus:hover {
|
||||
@@ -417,16 +418,16 @@
|
||||
}
|
||||
|
||||
.quiz-history-item {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.quiz-history-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: var(--bg-base);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.quiz-item-header {
|
||||
@@ -440,13 +441,13 @@
|
||||
.quiz-topic {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.quiz-item-meta {
|
||||
display: flex;
|
||||
font-size: 0.75rem;
|
||||
color: #666666;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -481,7 +482,7 @@
|
||||
|
||||
.empty-quiz-state .sub-text {
|
||||
font-size: 0.8rem;
|
||||
color: #666666;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -489,8 +490,8 @@
|
||||
.concept-detail-toast {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
@@ -514,7 +515,7 @@
|
||||
.concept-content {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: #E0E0E0;
|
||||
color: var(--text-main);
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -603,8 +604,8 @@
|
||||
/* --- Architecture Guide Block --- */
|
||||
.architecture-guide-panel {
|
||||
margin-top: 2.5rem;
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
}
|
||||
@@ -618,7 +619,7 @@
|
||||
.architecture-content h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 1.25rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@@ -626,16 +627,103 @@
|
||||
.architecture-content p {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
color: #e4e4e7;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.architecture-content code {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-base);
|
||||
color: #10b981;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||
============================================================ */
|
||||
|
||||
.theme-light .username::before,
|
||||
.theme-light .username::after {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-light .avatar-glow {
|
||||
background: var(--accent);
|
||||
filter: blur(15px);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.theme-light .progress-container {
|
||||
background: #e4e1d9;
|
||||
}
|
||||
|
||||
.theme-light .progress-bar {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.theme-light .progress-bubble {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-light .graph-node {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.theme-light .graph-node.central {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.theme-light .graph-node.satellite {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.theme-light .graph-node.satellite:hover {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
|
||||
.theme-light .active-node-label {
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-light .quiz-option.active {
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-light .btn-nexus.primary {
|
||||
background: var(--accent);
|
||||
color: #0d0d0d;
|
||||
}
|
||||
|
||||
.theme-light .btn-nexus.primary:hover {
|
||||
background: #059669;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-light .empty-icon {
|
||||
color: var(--accent);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.theme-light .badge-success {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.theme-light .concept-type {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
@page "/intelligence"
|
||||
@attribute [Authorize]
|
||||
@implements IDisposable
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.UI.Shared.Components.Molecules
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@using NexusReader.UI.Shared.Models
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Net.Http.Json
|
||||
@inject HttpClient Http
|
||||
@inject IKnowledgeService KnowledgeService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject ILibraryStateService LibraryStateService
|
||||
@inject ILogger<Intelligence> Logger
|
||||
|
||||
<div class="intelligence-page">
|
||||
<header class="intelligence-header">
|
||||
<div class="header-title-section">
|
||||
<h1 class="neon-glow-text">Global Intelligence</h1>
|
||||
<p class="subtitle">Interrogate, explore, and synthesize grounded knowledge from your library using Polyglot KM-RAG</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="intelligence-layout glass-panel">
|
||||
<div class="intelligence-layout">
|
||||
<div class="chat-thread-container">
|
||||
@if (_chatMessages.Count == 0)
|
||||
{
|
||||
<div class="welcome-state">
|
||||
<div class="welcome-icon">
|
||||
<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="#8b8273" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" stroke-dasharray="2 2" stroke="rgba(139, 130, 115, 0.3)" />
|
||||
<path d="M12 6v12M8 8h8M6 12h12M8 16h8" stroke="rgba(139, 130, 115, 0.4)" />
|
||||
<path d="M9.5 4.5c-1.5 0-3 1.5-3 3.5s1.5 3 3 3h5c1.5 0 3-1 3-3s-1.5-3.5-3-3.5" />
|
||||
<path d="M9.5 19.5c-1.5 0-3-1.5-3-3.5s1.5-3 3-3h5c1.5 0 3 1 3 3s-1.5 3.5-3 3.5" />
|
||||
<circle cx="12" cy="12" r="2" fill="#8b8273" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Start Interrogating Your Library</h3>
|
||||
<p>Ask complex questions across your entire ebook collection. The KM-RAG engine dynamically builds semantic maps, resolves dependencies, and formulates high-fidelity, grounded answers with interactive popover citations.</p>
|
||||
<div class="welcome-prompt">Zadaj pytanie globalne do całej biblioteki...</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -37,37 +38,7 @@
|
||||
<div class="chat-bubbles-scroll">
|
||||
@foreach (var message in _chatMessages)
|
||||
{
|
||||
<div class="message-row @(message.Sender == "User" ? "user-row" : "ai-row")" key="@message.Id">
|
||||
<div class="message-avatar">
|
||||
@if (message.Sender == "User")
|
||||
{
|
||||
<i class="bi bi-person-fill"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-robot"></i>
|
||||
}
|
||||
</div>
|
||||
<div class="message-bubble @(message.Sender == "User" ? "user-bubble" : "ai-bubble")">
|
||||
<div class="message-header">
|
||||
<span class="sender-name">@message.Sender</span>
|
||||
<span class="message-time">@message.Timestamp.ToString("HH:mm")</span>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
@foreach (var segment in message.Segments)
|
||||
{
|
||||
@if (segment.IsCitation)
|
||||
{
|
||||
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@message.Citations" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@RenderMarkdown(segment.Text)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AiResponseRenderer @key="message.Id" Message="@message" OwnedBooks="@_books" />
|
||||
}
|
||||
|
||||
@if (_isLoading)
|
||||
@@ -100,9 +71,9 @@
|
||||
<div class="input-panel-wrapper">
|
||||
<div class="scope-bar">
|
||||
<div class="scope-selector">
|
||||
<label for="book-select"><i class="bi bi-compass"></i> Scope:</label>
|
||||
<label for="book-select">Scope:</label>
|
||||
<select id="book-select" class="nexus-select" @bind="_selectedBookId">
|
||||
<option value="">All Books (Global Search)</option>
|
||||
<option value="">[ All Resources (Including Global Catalog) ]</option>
|
||||
@if (_books != null)
|
||||
{
|
||||
@foreach (var book in _books)
|
||||
@@ -121,7 +92,7 @@
|
||||
@bind:event="oninput"
|
||||
@onkeyup="HandleKeyUp"
|
||||
disabled="@_isLoading" />
|
||||
<button class="btn-nexus btn-nexus-primary search-btn"
|
||||
<button class="search-btn"
|
||||
disabled="@(string.IsNullOrWhiteSpace(_question) || _isLoading)"
|
||||
@onclick="AskQuestionAsync">
|
||||
@if (_isLoading)
|
||||
@@ -146,20 +117,52 @@
|
||||
private List<LastReadBookDto>? _books;
|
||||
private List<ChatMessage> _chatMessages = new();
|
||||
|
||||
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged += HandleBooksChanged;
|
||||
await LoadBooksAsync();
|
||||
}
|
||||
|
||||
private async Task LoadBooksAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_books = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
|
||||
LibraryStateService.OwnedBooks = _books;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Intelligence] Failed to load books for scope selector: {ex.Message}");
|
||||
Logger.LogError(ex, "[Intelligence] Failed to load books.");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleBooksChanged()
|
||||
{
|
||||
_ = InvokeAsync(async () =>
|
||||
{
|
||||
_books = LibraryStateService.OwnedBooks;
|
||||
|
||||
// Check if any existing message in the chat thread was paywalled,
|
||||
// and update its owned state dynamically.
|
||||
if (_books != null)
|
||||
{
|
||||
foreach (var message in _chatMessages)
|
||||
{
|
||||
if (message.IsPaywalled && !string.IsNullOrEmpty(message.SourceBookTitle))
|
||||
{
|
||||
var isNowOwned = _books.Any(b => b.Title.Equals(message.SourceBookTitle, StringComparison.OrdinalIgnoreCase));
|
||||
if (isNowOwned)
|
||||
{
|
||||
message.IsPaywalled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleKeyUp(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_question) && !_isLoading)
|
||||
@@ -176,7 +179,7 @@
|
||||
_question = string.Empty; // Clear input field immediately
|
||||
_isLoading = true;
|
||||
|
||||
// Add user query message
|
||||
// Add user query message with custom background in renderer
|
||||
_chatMessages.Add(new ChatMessage
|
||||
{
|
||||
Sender = "User",
|
||||
@@ -196,28 +199,80 @@
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
||||
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
|
||||
|
||||
var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
|
||||
if (result.IsSuccess)
|
||||
if (ebookId == null)
|
||||
{
|
||||
var response = result.Value;
|
||||
_chatMessages.Add(new ChatMessage
|
||||
var result = await KnowledgeService.GetGlobalIntelligenceAsync(userQuestion, userId, tenantId);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = response.Answer,
|
||||
Segments = ParseSegments(response.Answer),
|
||||
Citations = response.Citations
|
||||
});
|
||||
var response = result.Value;
|
||||
var chatMsg = CreateGlobalAiChatMessage(response, _books);
|
||||
_chatMessages.Add(chatMsg);
|
||||
}
|
||||
else
|
||||
{
|
||||
var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
|
||||
_chatMessages.Add(new ChatMessage
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = errMsg,
|
||||
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
|
||||
_chatMessages.Add(new ChatMessage
|
||||
var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = errMsg,
|
||||
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
||||
});
|
||||
var response = result.Value;
|
||||
|
||||
// --- Paywall Simulation Logic ---
|
||||
// If the user does not own "Architektura .NET 10 i Ekosystem Blazor"
|
||||
// and the question refers to Blazor/architecture/C#/etc,
|
||||
// we simulate that the RAG search pulled a citation from that unowned book.
|
||||
var hasBlazorBook = _books != null && _books.Any(b => b.Title.Contains("Architektura .NET 10", StringComparison.OrdinalIgnoreCase));
|
||||
var isSimulatingPaywall = !hasBlazorBook &&
|
||||
(userQuestion.Contains("blazor", StringComparison.OrdinalIgnoreCase) ||
|
||||
userQuestion.Contains("net", StringComparison.OrdinalIgnoreCase) ||
|
||||
userQuestion.Contains("architektura", StringComparison.OrdinalIgnoreCase) ||
|
||||
userQuestion.Contains("c#", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (isSimulatingPaywall)
|
||||
{
|
||||
var mockCitationId = Guid.NewGuid().ToString();
|
||||
var mockCitation = new CitationDto
|
||||
{
|
||||
CitationId = mockCitationId,
|
||||
SourceBook = "Architektura .NET 10 i Ekosystem Blazor",
|
||||
Author = "Nexus Architect",
|
||||
Snippet = "Konfiguracja kontenera dependency injection w standardzie .NET 10 Blazor przy użyciu Native AOT wymaga wyeliminowania dynamicznej refleksji.",
|
||||
PageNumber = 42
|
||||
};
|
||||
|
||||
if (response.Citations == null)
|
||||
{
|
||||
response.Citations = new List<CitationDto>();
|
||||
}
|
||||
response.Citations.Add(mockCitation);
|
||||
|
||||
response.Answer = "Aby poprawnie skonfigurować architekturę .NET 10 Blazor pod Native AOT, należy unikać dynamicznego ładowania typów. Konieczne jest używanie generatorów kodu źródłowego (Source Generators) do rejestracji zależności w kontenerze DI. W ten sposób kompilator AOT może przeanalizować graf zależności podczas kompilacji. [Source ID: " + mockCitationId + "]\n\nPełny przykładowy kod konfiguracji Program.cs wygląda następująco:\n```csharp\nvar builder = WebApplication.CreateBuilder(args);\nbuilder.Services.AddSingleton<IService, AotService>();\n```";
|
||||
}
|
||||
|
||||
var chatMsg = CreateAiChatMessage(response, _books);
|
||||
_chatMessages.Add(chatMsg);
|
||||
}
|
||||
else
|
||||
{
|
||||
var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
|
||||
_chatMessages.Add(new ChatMessage
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = errMsg,
|
||||
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -237,12 +292,115 @@
|
||||
}
|
||||
}
|
||||
|
||||
private ChatMessage CreateAiChatMessage(GroundedResponseDto response, List<LastReadBookDto>? ownedBooks)
|
||||
{
|
||||
var msg = new ChatMessage
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = response.Answer,
|
||||
Segments = ParseSegments(response.Answer),
|
||||
Citations = response.Citations
|
||||
};
|
||||
|
||||
// Check if paywalled: citations contain a book not in ownedBooks
|
||||
var unownedCitation = response.Citations.FirstOrDefault(c =>
|
||||
!string.IsNullOrEmpty(c.SourceBook) &&
|
||||
(ownedBooks == null || !ownedBooks.Any(ob => ob.Title.Equals(c.SourceBook, StringComparison.OrdinalIgnoreCase))));
|
||||
|
||||
if (unownedCitation != null)
|
||||
{
|
||||
msg.IsPaywalled = true;
|
||||
msg.SourceBookTitle = unownedCitation.SourceBook;
|
||||
|
||||
// Split sentences *once* during creation for Native AOT rendering performance
|
||||
var (clear, _) = SplitSentences(response.Answer);
|
||||
msg.ClearText = clear;
|
||||
msg.BlurredTeaserText = "\n\n// [Blokada Paywall] Pełna treść oraz kody źródłowe C# zostały zablokowane.\npublic class ArchitekturaProcessor {\n public async Task ProcessAsync() {\n // Zaimplementuj wzorzec CQRS...\n throw new PaywallException(\"Kup publikację w katalogu\");\n }\n}";
|
||||
}
|
||||
else
|
||||
{
|
||||
msg.IsPaywalled = false;
|
||||
msg.ClearText = response.Answer;
|
||||
msg.BlurredTeaserText = string.Empty;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
private ChatMessage CreateGlobalAiChatMessage(NexusReader.Application.Queries.Intelligence.IntelligenceResponse response, List<LastReadBookDto>? ownedBooks)
|
||||
{
|
||||
var msg = new ChatMessage
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = response.ResponseText,
|
||||
Segments = ParseSegments(response.ResponseText),
|
||||
Citations = response.Citations ?? new List<CitationDto>()
|
||||
};
|
||||
|
||||
if (response.HasPaywall)
|
||||
{
|
||||
msg.IsPaywalled = true;
|
||||
msg.SourceBookTitle = response.LockedBookTitle ?? string.Empty;
|
||||
|
||||
// Split sentences *once* during creation for Native AOT rendering performance
|
||||
var (clear, _) = SplitSentences(response.ResponseText);
|
||||
msg.ClearText = clear;
|
||||
msg.BlurredTeaserText = "\n\n// [Blokada Paywall] Pełna treść oraz kody źródłowe C# zostały zablokowane.\npublic class ArchitekturaProcessor {\n public async Task ProcessAsync() {\n // Zaimplementuj wzorzec CQRS...\n throw new PaywallException(\"Kup publikację w katalogu\");\n }\n}";
|
||||
}
|
||||
else
|
||||
{
|
||||
msg.IsPaywalled = false;
|
||||
msg.ClearText = response.ResponseText;
|
||||
msg.BlurredTeaserText = string.Empty;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
private (string ClearText, string BlurredText) SplitSentences(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return (string.Empty, string.Empty);
|
||||
|
||||
int sentenceCount = 0;
|
||||
int firstSplit = -1;
|
||||
int secondSplit = -1;
|
||||
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
char c = text[i];
|
||||
if (c == '.' || c == '?' || c == '!')
|
||||
{
|
||||
if (i + 1 == text.Length || char.IsWhiteSpace(text[i + 1]))
|
||||
{
|
||||
sentenceCount++;
|
||||
if (sentenceCount == 1)
|
||||
{
|
||||
firstSplit = i + 1;
|
||||
}
|
||||
else if (sentenceCount == 2)
|
||||
{
|
||||
secondSplit = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int splitIndex = secondSplit != -1 ? secondSplit : firstSplit;
|
||||
|
||||
if (splitIndex != -1 && splitIndex < text.Length)
|
||||
{
|
||||
return (text.Substring(0, splitIndex), text.Substring(splitIndex));
|
||||
}
|
||||
|
||||
return (text, string.Empty);
|
||||
}
|
||||
|
||||
private List<ResponseSegment> ParseSegments(string text)
|
||||
{
|
||||
var segments = new List<ResponseSegment>();
|
||||
if (string.IsNullOrEmpty(text)) return segments;
|
||||
|
||||
// Matches [Source ID: some-id] OR raw GUIDs in brackets [e225e58f-7539-cd51-e0ab-82741ec7e65c]
|
||||
var regex = new System.Text.RegularExpressions.Regex(
|
||||
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
@@ -285,28 +443,8 @@
|
||||
return segments;
|
||||
}
|
||||
|
||||
private MarkupString RenderMarkdown(string text)
|
||||
public void Dispose()
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
|
||||
|
||||
// 1. HTML Encode to prevent XSS
|
||||
var html = System.Net.WebUtility.HtmlEncode(text);
|
||||
|
||||
// 2. Bold: **text** -> <strong>text</strong>
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
|
||||
|
||||
// 3. Italic: *text* -> <em>text</em>
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
|
||||
|
||||
// 4. Code blocks: ```language ... ``` -> <pre class="nexus-code-block"><code>...</code></pre>
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-code-block\"><code>$1</code></pre>");
|
||||
|
||||
// 5. Inline Code: `code` -> <code class="nexus-inline-code">code</code>
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "<code class=\"nexus-inline-code\">$1</code>");
|
||||
|
||||
// 6. Newlines: \n -> <br />
|
||||
html = html.Replace("\n", "<br />");
|
||||
|
||||
return new MarkupString(html);
|
||||
LibraryStateService.OnBooksChanged -= HandleBooksChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,18 @@
|
||||
.intelligence-page {
|
||||
padding: 2rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
height: calc(100vh - 100px);
|
||||
margin: -2.5rem;
|
||||
height: 100vh;
|
||||
background: var(--bg-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.intelligence-header {
|
||||
margin-bottom: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.neon-glow-text {
|
||||
font-family: var(--nexus-font-sans);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 0.25rem 0;
|
||||
background: linear-gradient(135deg, var(--nexus-neon) 0%, rgba(0, 255, 153, 0.7) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.2));
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
@media (max-width: 768px) {
|
||||
.intelligence-page {
|
||||
margin: -1.25rem;
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
}
|
||||
|
||||
.intelligence-layout {
|
||||
@@ -35,202 +20,90 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-thread-container {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
padding: 3rem 4rem 2rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-thread-container {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Scrollbars */
|
||||
.chat-thread-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.chat-thread-container::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
background: transparent;
|
||||
}
|
||||
.chat-thread-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 255, 153, 0.2);
|
||||
background: var(--accent-glow);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.chat-thread-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 255, 153, 0.4);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.chat-bubbles-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 85%;
|
||||
animation: bubble-fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.ai-row {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
/* State 1: Initial Empty Screen */
|
||||
.welcome-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
animation: fade-in-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.user-row .message-avatar {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
|
||||
.welcome-icon {
|
||||
margin-bottom: 2rem;
|
||||
filter: drop-shadow(0 0 15px rgba(139, 130, 115, 0.15));
|
||||
}
|
||||
|
||||
.ai-row .message-avatar {
|
||||
background: linear-gradient(135deg, #005f38 0%, #004024 100%);
|
||||
color: #e6fffa;
|
||||
border: 1px solid rgba(0, 255, 153, 0.4);
|
||||
box-shadow: 0 0 10px rgba(0, 255, 153, 0.25);
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: var(--radius-lg);
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
font-size: 0.975rem;
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
border-top-right-radius: 4px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.ai-bubble {
|
||||
background: rgba(10, 20, 30, 0.55);
|
||||
border: 1px solid rgba(0, 255, 153, 0.2);
|
||||
color: #e2e8f0;
|
||||
border-top-left-radius: 4px;
|
||||
box-shadow: 0 4px 15px rgba(0, 255, 153, 0.05);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Paragraph Spacing & Markdown */
|
||||
.message-content p {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.message-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nexus-code-block {
|
||||
background: rgba(0, 0, 0, 0.4) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
.nexus-inline-code {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: #f472b6; /* Light pink for inline code */
|
||||
}
|
||||
|
||||
/* Pending State Bubble */
|
||||
.pending-bubble {
|
||||
border-color: rgba(0, 255, 153, 0.4);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.1);
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--nexus-neon);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: typing-bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
.loading-label {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-style: italic;
|
||||
.welcome-prompt {
|
||||
font-family: var(--nexus-font-sans, inherit);
|
||||
color: var(--text-main);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
/* Input Controls */
|
||||
.chat-input-controls {
|
||||
padding: 1.5rem 2rem 2rem 2rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 1.5rem 4rem 3rem 4rem;
|
||||
background: linear-gradient(to top, var(--bg-base) 70%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-input-controls {
|
||||
padding: 1rem 1rem 1.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.input-panel-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scope-bar {
|
||||
@@ -241,115 +114,143 @@
|
||||
.scope-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nexus-select {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
padding: 0.35rem 2rem 0.35rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-main);
|
||||
padding: 0.4rem 2rem 0.4rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.825rem;
|
||||
transition: all 0.25s ease;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b8273' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 0.85em;
|
||||
}
|
||||
|
||||
.nexus-select:focus {
|
||||
border-color: var(--nexus-neon);
|
||||
box-shadow: 0 0 8px rgba(0, 255, 153, 0.2);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
.input-field-group {
|
||||
display: flex;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.35rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.4rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.input-field-group:focus-within {
|
||||
border-color: var(--nexus-neon);
|
||||
background: rgba(0, 255, 153, 0.01);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.15);
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: 0 10px 35px var(--accent-glow);
|
||||
}
|
||||
|
||||
.nexus-input {
|
||||
flex-grow: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
color: var(--text-main);
|
||||
font-size: 0.975rem;
|
||||
outline: none;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nexus-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
padding: 0 !important;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: var(--bg-surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.welcome-state {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
padding: 4rem 2rem;
|
||||
.search-btn:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
opacity: 0.9;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.search-btn:disabled {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
border: 1px solid var(--border);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Typing / Loading Indicators */
|
||||
.message-bubble.pending-bubble {
|
||||
border-color: var(--accent-glow);
|
||||
background: var(--accent-glow);
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.welcome-icon {
|
||||
color: rgba(0, 255, 153, 0.4);
|
||||
margin-bottom: 1.5rem;
|
||||
filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.2));
|
||||
animation: pulse 2.5s infinite alternate;
|
||||
.typing-indicator span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: typing-bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.welcome-state h3 {
|
||||
color: #ffffff;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
.welcome-state p {
|
||||
max-width: 550px;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
.loading-label {
|
||||
font-size: 0.825rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 50%;
|
||||
border-top-color: #000000;
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Keyframe Animations */
|
||||
@keyframes bubble-fade-in {
|
||||
0% { opacity: 0; transform: translateY(10px) scale(0.98); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
0% { opacity: 0; transform: translateY(15px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes typing-bounce {
|
||||
@@ -357,12 +258,70 @@
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.96); opacity: 0.8; }
|
||||
100% { transform: scale(1.04); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||
============================================================ */
|
||||
|
||||
.theme-light .welcome-prompt {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-light .welcome-icon svg {
|
||||
stroke: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .welcome-icon svg circle {
|
||||
fill: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .welcome-icon svg path[stroke^="rgba(139"] {
|
||||
stroke: rgba(120, 113, 108, 0.4);
|
||||
}
|
||||
|
||||
.theme-light .welcome-icon svg path[stroke^="rgba(139"][stroke-dasharray] {
|
||||
stroke: rgba(120, 113, 108, 0.3);
|
||||
}
|
||||
|
||||
.theme-light .input-field-group {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 10px 30px rgba(139, 130, 115, 0.08);
|
||||
}
|
||||
|
||||
.theme-light .input-field-group:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 10px 35px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.theme-light .nexus-input {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-light .nexus-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .nexus-select {
|
||||
background-color: var(--bg-surface);
|
||||
border-color: var(--border);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-light .nexus-select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
.theme-light .chat-thread-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.theme-light .chat-thread-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
@page "/my-books"
|
||||
@attribute [Authorize]
|
||||
@implements IDisposable
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Net.Http.Json
|
||||
@inject HttpClient Http
|
||||
@inject IReaderNavigationService ReaderNavigation
|
||||
@inject ILibraryStateService LibraryStateService
|
||||
@inject ILogger<MyBooks> Logger
|
||||
|
||||
<div class="my-books-page">
|
||||
<header class="my-books-header">
|
||||
@@ -108,6 +112,11 @@
|
||||
private bool _isLoading = true;
|
||||
private List<LastReadBookDto>? _books;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged += HandleBooksChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
@@ -116,6 +125,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleBooksChanged()
|
||||
{
|
||||
_ = InvokeAsync(LoadBooksAsync);
|
||||
}
|
||||
|
||||
private async Task LoadBooksAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
@@ -128,7 +142,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[MyBooks] Failed to load books: {ex.Message}");
|
||||
Logger.LogError(ex, "[MyBooks] Failed to load books.");
|
||||
if (OperatingSystem.IsBrowser())
|
||||
{
|
||||
_isLoading = false;
|
||||
@@ -149,4 +163,9 @@
|
||||
{
|
||||
ReaderNavigation.NavigateToBook(bookId);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged -= HandleBooksChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.header-title-section .subtitle {
|
||||
font-size: 1rem;
|
||||
color: #a1a1aa;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -67,27 +67,27 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.book-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.book-cover-container {
|
||||
position: relative;
|
||||
height: 360px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.book-cover {
|
||||
@@ -145,7 +145,7 @@
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.4rem 0;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -154,7 +154,7 @@
|
||||
|
||||
.book-author {
|
||||
font-size: 0.9rem;
|
||||
color: #a1a1aa;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 1.25rem 0;
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -192,7 +192,7 @@
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.8rem;
|
||||
color: #a1a1aa;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -232,14 +232,14 @@
|
||||
justify-content: center;
|
||||
padding: 5rem 2rem;
|
||||
text-align: center;
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon-pulse {
|
||||
margin-bottom: 2rem;
|
||||
color: #a1a1aa;
|
||||
color: var(--text-muted);
|
||||
animation: pulse 3s infinite alternate;
|
||||
}
|
||||
|
||||
@@ -247,11 +247,11 @@
|
||||
font-family: var(--nexus-font-serif);
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.empty-state-container p {
|
||||
color: #a1a1aa;
|
||||
color: var(--text-muted);
|
||||
max-width: 400px;
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
@@ -278,14 +278,14 @@
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
height: 480px;
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.skeleton-cover {
|
||||
height: 360px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
|
||||
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
@@ -298,7 +298,7 @@
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.02) 25%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 75%);
|
||||
background: linear-gradient(90deg, var(--bg-base) 25%, var(--border) 50%, var(--bg-base) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
@@ -336,9 +336,9 @@
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem 2.25rem;
|
||||
border-radius: 40px;
|
||||
background: rgba(13, 13, 15, 0.85);
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid var(--border);
|
||||
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@
|
||||
|
||||
.loader-text {
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
color: var(--text-main);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@@ -383,3 +383,27 @@
|
||||
from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; }
|
||||
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LIGHT THEME OVERRIDES — Warm Paper / Soft Sepia
|
||||
============================================ */
|
||||
|
||||
.theme-light .book-card:hover {
|
||||
box-shadow: 0 12px 30px rgba(139, 130, 115, 0.15);
|
||||
}
|
||||
|
||||
.theme-light .book-cover-container {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.theme-light .cover-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.theme-light .book-card:hover .read-action {
|
||||
color: #292524;
|
||||
}
|
||||
|
||||
.theme-light .progress-bar {
|
||||
background: #e4e1d9;
|
||||
}
|
||||
|
||||
@@ -72,3 +72,40 @@
|
||||
from { opacity: 0; transform: translateY(15px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
||||
============================================================ */
|
||||
|
||||
.theme-light .settings-page > h1 {
|
||||
background: none;
|
||||
-webkit-text-fill-color: initial;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-light .settings-page > p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .settings-section h2 {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.theme-light .settings-section p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-light .diag-btn {
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.theme-light .diag-btn:hover {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface ILibraryStateService
|
||||
{
|
||||
List<LastReadBookDto>? OwnedBooks { get; set; }
|
||||
event Action? OnBooksChanged;
|
||||
void NotifyBooksChanged();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NexusReader.Application.Queries.Recommendations;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides contextual book recommendations based on the user's active reading state.
|
||||
/// Abstracts the HTTP transport layer from Blazor UI components.
|
||||
/// </summary>
|
||||
public interface IRecommendationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches contextual recommendations for the authenticated user.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>
|
||||
/// A list of <see cref="RecommendationDto"/> on success, or an empty list when none are available.
|
||||
/// Returns <c>null</c> if the request fails due to a transport or server error.
|
||||
/// </returns>
|
||||
Task<List<RecommendationDto>?> GetRecommendationsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface IThemeService
|
||||
{
|
||||
ThemeMode Mode { get; }
|
||||
bool IsLightMode { get; }
|
||||
event Func<Task>? OnThemeChanged;
|
||||
event Action<ThemeMode>? OnThemeChanged;
|
||||
|
||||
Task InitializeAsync();
|
||||
Task SetThemeAsync(ThemeMode mode);
|
||||
Task ToggleTheme();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public class LibraryStateService : ILibraryStateService
|
||||
{
|
||||
private List<LastReadBookDto>? _ownedBooks;
|
||||
|
||||
public List<LastReadBookDto>? OwnedBooks
|
||||
{
|
||||
get => _ownedBooks;
|
||||
set
|
||||
{
|
||||
_ownedBooks = value;
|
||||
NotifyBooksChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public event Action? OnBooksChanged;
|
||||
|
||||
public void NotifyBooksChanged()
|
||||
{
|
||||
OnBooksChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AOT-safe string parsing utility to isolate paywall teaser details without regex overhead.
|
||||
/// </summary>
|
||||
public static class PaywallParser
|
||||
{
|
||||
public static bool TryParsePaywallTrigger(
|
||||
string rawText,
|
||||
out string displayTeaserText,
|
||||
out Guid lockedBookId,
|
||||
out string lockedBookTitle,
|
||||
out int localScore,
|
||||
out int globalScore)
|
||||
{
|
||||
displayTeaserText = rawText;
|
||||
lockedBookId = Guid.Empty;
|
||||
lockedBookTitle = string.Empty;
|
||||
localScore = 0;
|
||||
globalScore = 0;
|
||||
|
||||
if (string.IsNullOrEmpty(rawText))
|
||||
return false;
|
||||
|
||||
ReadOnlySpan<char> span = rawText.AsSpan();
|
||||
int tokenStartIndex = span.IndexOf("[PAYWALL_TRIGGER:");
|
||||
if (tokenStartIndex == -1)
|
||||
return false;
|
||||
|
||||
displayTeaserText = span.Slice(0, tokenStartIndex).Trim().ToString();
|
||||
|
||||
ReadOnlySpan<char> tokenContent = span.Slice(tokenStartIndex + "[PAYWALL_TRIGGER:".Length);
|
||||
int tokenEndIndex = tokenContent.IndexOf(']');
|
||||
if (tokenEndIndex == -1)
|
||||
return false;
|
||||
|
||||
tokenContent = tokenContent.Slice(0, tokenEndIndex);
|
||||
|
||||
int firstColonIdx = tokenContent.IndexOf(':');
|
||||
if (firstColonIdx == -1)
|
||||
return false;
|
||||
|
||||
ReadOnlySpan<char> guidSpan = tokenContent.Slice(0, firstColonIdx);
|
||||
if (!Guid.TryParse(guidSpan, out lockedBookId))
|
||||
return false;
|
||||
|
||||
ReadOnlySpan<char> remaining = tokenContent.Slice(firstColonIdx + 1);
|
||||
|
||||
int lastColonIdx = remaining.LastIndexOf(':');
|
||||
if (lastColonIdx == -1)
|
||||
return false;
|
||||
|
||||
ReadOnlySpan<char> globalScoreSpan = remaining.Slice(lastColonIdx + 1);
|
||||
if (!int.TryParse(globalScoreSpan, out globalScore))
|
||||
return false;
|
||||
|
||||
remaining = remaining.Slice(0, lastColonIdx);
|
||||
|
||||
int secondLastColonIdx = remaining.LastIndexOf(':');
|
||||
if (secondLastColonIdx == -1)
|
||||
return false;
|
||||
|
||||
ReadOnlySpan<char> localScoreSpan = remaining.Slice(secondLastColonIdx + 1);
|
||||
if (!int.TryParse(localScoreSpan, out localScore))
|
||||
return false;
|
||||
|
||||
lockedBookTitle = remaining.Slice(0, secondLastColonIdx).ToString();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,155 @@
|
||||
using Microsoft.JSInterop;
|
||||
using NexusReader.Domain.Enums;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public sealed class ThemeService : IThemeService
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
public bool IsLightMode { get; private set; } = false;
|
||||
public event Func<Task>? OnThemeChanged;
|
||||
private readonly IUserPreferenceStore _userPreferenceStore;
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
private bool _isInitialized;
|
||||
private bool _systemPrefersLight;
|
||||
|
||||
public ThemeService(IJSRuntime jsRuntime)
|
||||
public ThemeMode Mode { get; private set; } = ThemeMode.System;
|
||||
|
||||
public bool IsLightMode => Mode == ThemeMode.LightSepia || (Mode == ThemeMode.System && _systemPrefersLight);
|
||||
|
||||
public event Action<ThemeMode>? OnThemeChanged;
|
||||
|
||||
public ThemeService(IJSRuntime jsRuntime, IUserPreferenceStore userPreferenceStore)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
_userPreferenceStore = userPreferenceStore;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (_isInitialized) return;
|
||||
|
||||
await _semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
IsLightMode = await _jsRuntime.InvokeAsync<bool>("themeInterop.isLightMode");
|
||||
if (OnThemeChanged != null) await OnThemeChanged();
|
||||
if (_isInitialized) return;
|
||||
|
||||
ThemeMode localMode = ThemeMode.System;
|
||||
try
|
||||
{
|
||||
var cachedThemeVal = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "theme-mode");
|
||||
if (Enum.TryParse<ThemeMode>(cachedThemeVal, out var parsedMode))
|
||||
{
|
||||
localMode = parsedMode;
|
||||
}
|
||||
else if (cachedThemeVal == "light" || cachedThemeVal == "theme-light")
|
||||
{
|
||||
localMode = ThemeMode.LightSepia;
|
||||
}
|
||||
else if (cachedThemeVal == "dark" || cachedThemeVal == "theme-dark")
|
||||
{
|
||||
localMode = ThemeMode.Dark;
|
||||
}
|
||||
|
||||
_systemPrefersLight = await _jsRuntime.InvokeAsync<bool>("themeInterop.isSystemLight");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silent catch for pre-rendering or unit tests
|
||||
}
|
||||
|
||||
Mode = localMode;
|
||||
_isInitialized = true;
|
||||
|
||||
await ApplyThemeToDomAsync(Mode);
|
||||
|
||||
// Asynchronously sync with the cloud to check for updates from other devices
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var cloudResult = await _userPreferenceStore.GetThemePreferenceAsync();
|
||||
if (cloudResult.IsSuccess && cloudResult.Value != Mode)
|
||||
{
|
||||
await SetThemeInternalAsync(cloudResult.Value, saveToCloud: false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fail silently for background task/network errors
|
||||
}
|
||||
});
|
||||
}
|
||||
catch
|
||||
finally
|
||||
{
|
||||
// Fail silently during prerendering or if JS is not available yet
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetThemeAsync(ThemeMode mode)
|
||||
{
|
||||
await SetThemeInternalAsync(mode, saveToCloud: true);
|
||||
}
|
||||
|
||||
private async Task SetThemeInternalAsync(ThemeMode mode, bool saveToCloud)
|
||||
{
|
||||
await _semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (Mode == mode && _isInitialized) return;
|
||||
|
||||
Mode = mode;
|
||||
_isInitialized = true;
|
||||
|
||||
await ApplyThemeToDomAsync(mode);
|
||||
|
||||
OnThemeChanged?.Invoke(mode);
|
||||
|
||||
if (saveToCloud)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _userPreferenceStore.SaveThemePreferenceAsync(mode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fail silently for background cloud sync errors
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ToggleTheme()
|
||||
{
|
||||
IsLightMode = !IsLightMode;
|
||||
var nextMode = IsLightMode ? ThemeMode.Dark : ThemeMode.LightSepia;
|
||||
await SetThemeAsync(nextMode);
|
||||
}
|
||||
|
||||
private async Task ApplyThemeToDomAsync(ThemeMode mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _jsRuntime.InvokeVoidAsync("themeInterop.setLightMode", IsLightMode);
|
||||
string themeClass = "theme-dark"; // Default
|
||||
if (mode == ThemeMode.LightSepia)
|
||||
{
|
||||
themeClass = "theme-light";
|
||||
}
|
||||
else if (mode == ThemeMode.System)
|
||||
{
|
||||
themeClass = _systemPrefersLight ? "theme-light" : "theme-dark";
|
||||
}
|
||||
|
||||
await _jsRuntime.InvokeVoidAsync("themeInterop.setCachedTheme", themeClass, ((int)mode).ToString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fail silently
|
||||
// Silent catch for pre-rendering
|
||||
}
|
||||
if (OnThemeChanged != null) await OnThemeChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,3 +20,5 @@
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.Application.Queries.Reader
|
||||
@using NexusReader.Application.Queries.Recommendations
|
||||
@using NexusReader.Domain.Enums
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&display=swap');
|
||||
|
||||
:root {
|
||||
--nexus-neon: #00ff99;
|
||||
--nexus-neon-glow: rgba(0, 255, 153, 0.3);
|
||||
--nexus-bg: #121214;
|
||||
--nexus-card: #1a1a1e;
|
||||
--nexus-text: #ffffff;
|
||||
/* Semantic design tokens - default to Modern Deep Dark (Dark Mode) */
|
||||
--bg-base: #121214;
|
||||
--bg-surface: #1a1a1e;
|
||||
--text-main: #ffffff;
|
||||
--text-muted: #a1a1aa;
|
||||
--accent: #00ff99;
|
||||
--accent-glow: rgba(0, 255, 153, 0.3);
|
||||
|
||||
/* Legacy mapping for backwards compatibility */
|
||||
--nexus-neon: var(--accent);
|
||||
--nexus-neon-glow: var(--accent-glow);
|
||||
--nexus-bg: var(--bg-base);
|
||||
--nexus-card: var(--bg-surface);
|
||||
--nexus-text: var(--text-main);
|
||||
--nexus-paper: #F9F9F9;
|
||||
--nexus-font-sans: 'Inter', sans-serif;
|
||||
--nexus-font-serif: 'Merriweather', serif;
|
||||
|
||||
/* Global Selection Style Override */
|
||||
--nexus-selection: rgba(0, 255, 153, 0.25);
|
||||
--nexus-accent: var(--accent);
|
||||
|
||||
/* Graph Nodes Theme Custom Properties (Dark Mode) */
|
||||
--nexus-graph-bg: radial-gradient(circle, #1a1a1a 0%, #121212 100%);
|
||||
@@ -56,24 +66,25 @@
|
||||
|
||||
|
||||
/* Global Semantic Theme Mapping */
|
||||
--nexus-primary: var(--nexus-neon);
|
||||
--nexus-primary-glow: var(--nexus-neon-glow);
|
||||
--nexus-primary-hover: #00e688;
|
||||
:root {
|
||||
--nexus-primary: var(--nexus-neon);
|
||||
--nexus-primary-glow: var(--nexus-neon-glow);
|
||||
--nexus-primary-hover: #00e688;
|
||||
|
||||
/* Standard Layout Tokens */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
/* Standard Layout Tokens */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
|
||||
/* Safe Area Insets with fallbacks */
|
||||
--safe-area-inset-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
||||
/* Safe Area Insets with fallbacks */
|
||||
--safe-area-inset-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
||||
|
||||
/* Transitions */
|
||||
--nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* Transitions */
|
||||
--nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Global Glassmorphism with Fallback */
|
||||
@@ -133,11 +144,38 @@
|
||||
}
|
||||
|
||||
|
||||
.theme-dark {
|
||||
/* Semantic design tokens - Modern Deep Dark */
|
||||
--bg-base: #121214;
|
||||
--bg-surface: #1a1a1e;
|
||||
--text-main: #ffffff;
|
||||
--text-muted: #a1a1aa;
|
||||
--accent: #00ff99;
|
||||
--accent-glow: rgba(0, 255, 153, 0.3);
|
||||
|
||||
/* Legacy mapping for backwards compatibility */
|
||||
--nexus-bg: var(--bg-base);
|
||||
--nexus-card: var(--bg-surface);
|
||||
--nexus-text: var(--text-main);
|
||||
--nexus-selection: rgba(0, 255, 153, 0.25);
|
||||
--nexus-accent: var(--accent);
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
--nexus-bg: #f4f1ea;
|
||||
--nexus-card: #ffffff;
|
||||
--nexus-text: #2d2a26;
|
||||
/* Semantic design tokens - Warm Paper / Soft Sepia */
|
||||
--bg-base: #f4f1ea;
|
||||
--bg-surface: #ffffff;
|
||||
--text-main: #2d2a26;
|
||||
--text-muted: #78716c;
|
||||
--accent: #10b981;
|
||||
--accent-glow: rgba(16, 185, 129, 0.2);
|
||||
|
||||
/* Legacy mapping for backwards compatibility */
|
||||
--nexus-bg: var(--bg-base);
|
||||
--nexus-card: var(--bg-surface);
|
||||
--nexus-text: var(--text-main);
|
||||
--nexus-selection: rgba(16, 185, 129, 0.18);
|
||||
--nexus-accent: var(--accent);
|
||||
|
||||
/* Graph Nodes Theme Custom Properties (Light Mode) */
|
||||
--nexus-graph-bg: radial-gradient(circle, #ffffff 0%, #e8e4da 100%);
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
window.themeInterop = {
|
||||
isSystemLight: function () {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||
},
|
||||
setCachedTheme: function (themeClass, modeValue) {
|
||||
localStorage.setItem('theme-mode', modeValue);
|
||||
localStorage.setItem('theme', themeClass === 'theme-light' ? 'light' : 'dark');
|
||||
|
||||
if (themeClass === 'theme-light') {
|
||||
document.documentElement.classList.add('theme-light');
|
||||
document.documentElement.classList.remove('theme-dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('theme-dark');
|
||||
document.documentElement.classList.remove('theme-light');
|
||||
}
|
||||
},
|
||||
isLightMode: function () {
|
||||
return document.documentElement.classList.contains('theme-light');
|
||||
},
|
||||
setLightMode: function (isLight) {
|
||||
if (isLight) {
|
||||
document.documentElement.classList.add('theme-light');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.remove('theme-light');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
var themeClass = isLight ? 'theme-light' : 'theme-dark';
|
||||
var modeValue = isLight ? '2' : '1';
|
||||
this.setCachedTheme(themeClass, modeValue);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,7 +17,8 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
// Platform & UI Services
|
||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||
builder.Services.AddScoped<INativeStorageService, WebStorageService>();
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddSingleton<IUserPreferenceStore, CloudUserPreferenceStore>();
|
||||
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
||||
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||
builder.Services.AddSingleton(featureSettings);
|
||||
@@ -27,6 +28,7 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
|
||||
builder.Services.AddScoped<ILibraryStateService, LibraryStateService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
|
||||
@@ -41,6 +43,7 @@ builder.Services.AddCascadingAuthenticationState();
|
||||
// AI & Content Services
|
||||
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
|
||||
builder.Services.AddScoped<IConceptsMapService, WasmConceptsMapService>();
|
||||
builder.Services.AddScoped<IRecommendationService, RecommendationService>();
|
||||
|
||||
builder.Services.AddTransient<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>();
|
||||
builder.Services.AddHttpClient("NexusAPI", client =>
|
||||
@@ -59,6 +62,10 @@ builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepos
|
||||
builder.Services.AddSingleton<IConceptsMapReadRepository>(new ThrowingConceptsMapReadRepository());
|
||||
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
|
||||
builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor());
|
||||
builder.Services.AddSingleton<IUserLibraryStore>(new ThrowingUserLibraryStore());
|
||||
builder.Services.AddSingleton<IVectorSearchStore>(new ThrowingVectorSearchStore());
|
||||
builder.Services.Configure<NexusReader.Application.Common.RagMonetizationOptions>(builder.Configuration.GetSection(NexusReader.Application.Common.RagMonetizationOptions.SectionName));
|
||||
builder.Services.AddSingleton<IChatClient>(new ThrowingChatClient());
|
||||
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddScoped<IEpubReader, WasmEpubReader>();
|
||||
@@ -132,3 +139,37 @@ public class ThrowingEpubExtractor : IEpubExtractor
|
||||
=> throw new NotSupportedException("EPUB text extraction is not supported in the WASM client.");
|
||||
}
|
||||
|
||||
public class ThrowingUserLibraryStore : IUserLibraryStore
|
||||
{
|
||||
public Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("UserLibrary operations are not supported in the WASM client.");
|
||||
|
||||
public Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("UserLibrary operations are not supported in the WASM client.");
|
||||
}
|
||||
|
||||
public class ThrowingVectorSearchStore : IVectorSearchStore
|
||||
{
|
||||
public Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
|
||||
|
||||
public Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
|
||||
|
||||
public Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("VectorSearch operations are not supported in the WASM client.");
|
||||
}
|
||||
|
||||
public class ThrowingChatClient : IChatClient
|
||||
{
|
||||
public void Dispose() { }
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Chat operations are not supported in the WASM client.");
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Chat operations are not supported in the WASM client.");
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using FluentResults;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Domain.Enums;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Web.Client.Services;
|
||||
|
||||
public class CloudUserPreferenceStore : IUserPreferenceStore
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public CloudUserPreferenceStore(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
private HttpClient CreateClient() => _httpClientFactory.CreateClient("NexusAPI");
|
||||
|
||||
public async Task<Result> SaveThemePreferenceAsync(ThemeMode mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = CreateClient();
|
||||
var response = await client.PostAsJsonAsync("identity/theme", new UpdateThemeRequest(mode));
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
return Result.Fail($"Failed to save cloud theme preference: {error}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Network error saving theme preference to cloud.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<ThemeMode>> GetThemePreferenceAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = CreateClient();
|
||||
var response = await client.GetAsync("identity/profile");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var profile = await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||
return profile != null
|
||||
? Result.Ok(profile.ThemePreference)
|
||||
: Result.Fail("Failed to deserialize profile response.");
|
||||
}
|
||||
return Result.Fail($"Failed to fetch theme preference from cloud: {response.ReasonPhrase}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Network error retrieving theme preference from cloud.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NexusReader.Application.Common;
|
||||
using NexusReader.Application.Queries.Recommendations;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
|
||||
namespace NexusReader.Web.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// WASM implementation of <see cref="IRecommendationService"/> that fetches contextual recommendations
|
||||
/// from the <c>/api/recommendations</c> server endpoint via a named <c>NexusAPI</c> HTTP client.
|
||||
/// </summary>
|
||||
internal sealed class RecommendationService : IRecommendationService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<RecommendationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="RecommendationService"/>.
|
||||
/// </summary>
|
||||
public RecommendationService(HttpClient http, ILogger<RecommendationService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<RecommendationDto>?> GetRecommendationsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.GetAsync("/api/recommendations", cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NoContent || response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogInformation("[RecommendationService] No recommendations available (status {StatusCode}).", response.StatusCode);
|
||||
return new List<RecommendationDto>();
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync(
|
||||
AppJsonContext.Default.ContextualRecommendationResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
_logger.LogWarning("[RecommendationService] Deserialised response was null.");
|
||||
return new List<RecommendationDto>();
|
||||
}
|
||||
|
||||
_logger.LogInformation("[RecommendationService] Received {Count} recommendations.", result.Recommendations.Count);
|
||||
return result.Recommendations;
|
||||
}
|
||||
catch (HttpRequestException httpEx)
|
||||
{
|
||||
_logger.LogError(httpEx, "[RecommendationService] HTTP error fetching recommendations: {Message}", httpEx.Message);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[RecommendationService] Unexpected error fetching recommendations.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using System.Net.Http.Json;
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
using NexusReader.Application.Common;
|
||||
using NexusReader.Application.Queries.Intelligence;
|
||||
|
||||
namespace NexusReader.Web.Client.Services;
|
||||
|
||||
@@ -113,6 +115,34 @@ public class WasmKnowledgeService : IKnowledgeService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/intelligence",
|
||||
new GetGlobalIntelligenceRequest(queryText),
|
||||
AppJsonContext.Default.GetGlobalIntelligenceRequest,
|
||||
cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<IntelligenceResponse>(
|
||||
AppJsonContext.Default.IntelligenceResponse,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return result != null ? Result.Ok(result) : Result.Fail("Failed to deserialize global intelligence response.");
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Network error: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, Guid? ebookId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -11,13 +11,28 @@
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isLight = savedTheme === 'light' || (!savedTheme && !systemPrefersDark);
|
||||
if (isLight) {
|
||||
document.documentElement.classList.add('theme-light');
|
||||
} else {
|
||||
document.documentElement.classList.remove('theme-light');
|
||||
try {
|
||||
var themeMode = localStorage.getItem('theme-mode');
|
||||
var savedTheme = localStorage.getItem('theme');
|
||||
var isLight = false;
|
||||
|
||||
if (themeMode === '2' || savedTheme === 'light') {
|
||||
isLight = true;
|
||||
} else if (themeMode === '1' || savedTheme === 'dark') {
|
||||
isLight = false;
|
||||
} else {
|
||||
isLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||
}
|
||||
|
||||
if (isLight) {
|
||||
document.documentElement.classList.add('theme-light');
|
||||
document.documentElement.classList.remove('theme-dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('theme-dark');
|
||||
document.documentElement.classList.remove('theme-light');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fail silently
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -36,6 +36,11 @@ builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents()
|
||||
.AddInteractiveWebAssemblyComponents();
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.TypeInfoResolverChain.Insert(0, NexusReader.Application.Common.AppJsonContext.Default);
|
||||
});
|
||||
|
||||
// Enable detailed circuit errors for Server‑Side Blazor components
|
||||
builder.Services.AddServerSideBlazor()
|
||||
.AddCircuitOptions(options =>
|
||||
@@ -47,7 +52,9 @@ builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<IUserPreferenceStore, NexusReader.Web.Services.ServerUserPreferenceStore>();
|
||||
builder.Services.AddScoped<IThemeService, NexusReader.Web.Services.ServerThemeService>();
|
||||
builder.Services.AddScoped<IRecommendationService, NexusReader.Web.Services.ServerRecommendationService>();
|
||||
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
||||
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||
builder.Services.AddSingleton(featureSettings);
|
||||
@@ -57,6 +64,7 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
|
||||
builder.Services.AddScoped<ILibraryStateService, LibraryStateService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
|
||||
@@ -414,6 +422,37 @@ knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
|
||||
return Results.BadRequest(errorMsg);
|
||||
});
|
||||
|
||||
app.MapPost("/api/intelligence", async (
|
||||
[FromBody] NexusReader.Application.Queries.Intelligence.GetGlobalIntelligenceRequest request,
|
||||
ClaimsPrincipal user,
|
||||
IMediator mediator) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||
|
||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||
|
||||
var result = await mediator.Send(new NexusReader.Application.Queries.Intelligence.GetGlobalIntelligenceQuery(request.QueryText, userId, tenantId));
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
|
||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Failed to execute global intelligence query";
|
||||
return Results.BadRequest(errorMsg);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/recommendations", async (
|
||||
ClaimsPrincipal user,
|
||||
IMediator mediator) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||
|
||||
var result = await mediator.Send(new NexusReader.Application.Queries.Recommendations.GetContextualRecommendationsQuery(userId));
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
|
||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Failed to fetch contextual recommendations";
|
||||
return Results.BadRequest(errorMsg);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
@@ -454,6 +493,48 @@ app.MapGet("/api/library/books", async (ClaimsPrincipal user, IMediator mediator
|
||||
return Results.BadRequest(errorMsg);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPost("/api/library/purchase", async (
|
||||
ClaimsPrincipal user,
|
||||
[FromBody] PurchaseBookRequest request,
|
||||
IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
// Find or create author
|
||||
var authorName = "Nexus Architect";
|
||||
var author = await dbContext.Authors.FirstOrDefaultAsync(a => a.Name == authorName);
|
||||
if (author == null)
|
||||
{
|
||||
author = new Author { Name = authorName };
|
||||
dbContext.Authors.Add(author);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Check if the book already exists for the user
|
||||
var bookExists = await dbContext.Ebooks.AnyAsync(e => e.UserId == userId && e.Title == request.Title);
|
||||
if (!bookExists)
|
||||
{
|
||||
var newBook = new Ebook
|
||||
{
|
||||
Title = request.Title,
|
||||
AuthorId = author.Id,
|
||||
UserId = userId,
|
||||
FilePath = "wwwroot/assets/book.epub",
|
||||
AddedDate = DateTime.UtcNow,
|
||||
Progress = 0,
|
||||
Description = "Zaawansowany kurs budowania skalowalnych SaaS z Native AOT, CQRS, MediatR, FluentResults i izolowanym systemem stylów Blazor CSS.",
|
||||
IsReadyForReading = true
|
||||
};
|
||||
dbContext.Ebooks.Add(newBook);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Results.Ok();
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/book/{bookId:guid}/concepts-map", async (
|
||||
Guid bookId,
|
||||
ClaimsPrincipal user,
|
||||
@@ -674,6 +755,20 @@ app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator)
|
||||
return Results.Ok(result.Value);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPost("/identity/theme", async (
|
||||
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.User.UpdateThemeRequest request,
|
||||
ClaimsPrincipal user,
|
||||
IMediator mediator) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||
|
||||
var result = await mediator.Send(new NexusReader.Application.Commands.User.UpdateThemeCommand(userId, request.Mode));
|
||||
if (result.IsFailed) return Results.BadRequest(result.Errors.FirstOrDefault()?.Message);
|
||||
|
||||
return Results.Ok();
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddInteractiveWebAssemblyRenderMode()
|
||||
@@ -729,3 +824,4 @@ public record KnowledgeRequest(string Text, Guid? EbookId = null);
|
||||
public record GroundednessRequest(string Answer, string Context);
|
||||
public record SemanticSearchRequest(string QueryText, int Limit = 5);
|
||||
public record AskQuestionRequest(string Question, Guid? EbookId = null, int Limit = 5);
|
||||
public record PurchaseBookRequest(string Title);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using NexusReader.Application.Queries.Recommendations;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
|
||||
namespace NexusReader.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side implementation of <see cref="IRecommendationService"/> that executes
|
||||
/// the MediatR query directly inside the Web Server's request context.
|
||||
/// </summary>
|
||||
public sealed class ServerRecommendationService : IRecommendationService
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public ServerRecommendationService(IMediator mediator, IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public async Task<List<RecommendationDto>?> GetRecommendationsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext?.User == null)
|
||||
{
|
||||
return new List<RecommendationDto>();
|
||||
}
|
||||
|
||||
var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
return new List<RecommendationDto>();
|
||||
}
|
||||
|
||||
var result = await _mediator.Send(new GetContextualRecommendationsQuery(userId), cancellationToken);
|
||||
if (result.IsSuccess && result.Value != null)
|
||||
{
|
||||
return result.Value.Recommendations;
|
||||
}
|
||||
|
||||
return new List<RecommendationDto>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using NexusReader.Domain.Enums;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
|
||||
namespace NexusReader.Web.Services;
|
||||
|
||||
public sealed class ServerThemeService : IThemeService
|
||||
{
|
||||
public ThemeMode Mode => ThemeMode.System;
|
||||
public bool IsLightMode => false;
|
||||
|
||||
// Explicit event implementation to avoid CS0067 warning about unused events on the server
|
||||
public event Action<ThemeMode>? OnThemeChanged
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task SetThemeAsync(ThemeMode mode) => Task.CompletedTask;
|
||||
public Task ToggleTheme() => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Security.Claims;
|
||||
using FluentResults;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Enums;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Web.Services;
|
||||
|
||||
public class ServerUserPreferenceStore : IUserPreferenceStore
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public ServerUserPreferenceStore(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public async Task<Result> SaveThemePreferenceAsync(ThemeMode mode)
|
||||
{
|
||||
var userId = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
return Result.Fail("User is not authenticated on the server.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var user = await dbContext.Users
|
||||
.AsTracking()
|
||||
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Fail("User not found in database.");
|
||||
}
|
||||
|
||||
user.ThemePreference = mode;
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Database failure updating theme preference.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<ThemeMode>> GetThemePreferenceAsync()
|
||||
{
|
||||
var userId = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
return Result.Fail("User is not authenticated on the server.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Fail("User not found in database.");
|
||||
}
|
||||
|
||||
return Result.Ok(user.ThemePreference);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Database failure reading theme preference.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,10 @@
|
||||
"AllowRegistration": false,
|
||||
"AllowPasswordReset": false
|
||||
},
|
||||
"ApiBaseUrl": "http://localhost:5000"
|
||||
"RagMonetization": {
|
||||
"BaselineThreshold": 0.45,
|
||||
"DeltaThreshold": 0.15,
|
||||
"UpgradeThreshold": 0.70
|
||||
},
|
||||
"ApiBaseUrl": "http://localhost:5104"
|
||||
}
|
||||
|
||||
@@ -31,5 +31,10 @@
|
||||
"MaxOutputTokens": 8192
|
||||
}
|
||||
},
|
||||
"ApiBaseUrl": "http://localhost:5000"
|
||||
"RagMonetization": {
|
||||
"BaselineThreshold": 0.45,
|
||||
"DeltaThreshold": 0.15,
|
||||
"UpgradeThreshold": 0.70
|
||||
},
|
||||
"ApiBaseUrl": "http://localhost:5104"
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Queries.Recommendations;
|
||||
using NexusReader.Infrastructure.Queries;
|
||||
using Xunit;
|
||||
|
||||
namespace NexusReader.Application.Tests.Queries;
|
||||
|
||||
public class GetContextualRecommendationsQueryTests
|
||||
{
|
||||
private readonly Mock<IUserReadingStateStore> _readingStateStoreMock;
|
||||
private readonly Mock<IUserLibraryStore> _libraryStoreMock;
|
||||
private readonly Mock<IVectorSearchStore> _vectorSearchStoreMock;
|
||||
private readonly Mock<ILogger<GetContextualRecommendationsQueryHandler>> _loggerMock;
|
||||
private readonly GetContextualRecommendationsQueryHandler _handler;
|
||||
|
||||
public GetContextualRecommendationsQueryTests()
|
||||
{
|
||||
_readingStateStoreMock = new Mock<IUserReadingStateStore>();
|
||||
_libraryStoreMock = new Mock<IUserLibraryStore>();
|
||||
_vectorSearchStoreMock = new Mock<IVectorSearchStore>();
|
||||
_loggerMock = new Mock<ILogger<GetContextualRecommendationsQueryHandler>>();
|
||||
|
||||
_handler = new GetContextualRecommendationsQueryHandler(
|
||||
_readingStateStoreMock.Object,
|
||||
_libraryStoreMock.Object,
|
||||
_vectorSearchStoreMock.Object,
|
||||
_loggerMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNoActiveReadingState_ReturnsEmptyRecommendations()
|
||||
{
|
||||
// Arrange
|
||||
var userId = "user-123";
|
||||
_readingStateStoreMock.Setup(s => s.GetActiveReadingStateAsync(userId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((null, null, null));
|
||||
|
||||
var query = new GetContextualRecommendationsQuery(userId);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Recommendations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithActiveReadingState_PerformsSimilaritySearchAndReturnsRecommendations()
|
||||
{
|
||||
// Arrange
|
||||
var userId = "user-123";
|
||||
var activeEbookId = Guid.NewGuid();
|
||||
var activeChapterId = "chapter-1";
|
||||
var tenantId = "tenant-abc";
|
||||
var chapterContent = "Active chapter content description";
|
||||
|
||||
_readingStateStoreMock.Setup(s => s.GetActiveReadingStateAsync(userId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((activeEbookId, activeChapterId, tenantId));
|
||||
|
||||
_readingStateStoreMock.Setup(s => s.GetChapterContentAsync(activeChapterId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(chapterContent);
|
||||
|
||||
// Mock vector search results using clean VectorChunk list
|
||||
var targetEbookId1 = Guid.NewGuid();
|
||||
var targetEbookId2 = Guid.NewGuid();
|
||||
|
||||
var mockChunks = new List<VectorChunk>
|
||||
{
|
||||
new VectorChunk(
|
||||
Content: "Result pattern details",
|
||||
EbookId: targetEbookId1.ToString(),
|
||||
Score: 0.88,
|
||||
MetadataJson: "",
|
||||
BookTitle: "Clean Architecture deep dive",
|
||||
ChapterTitle: "Chapter 3: Result Pattern"
|
||||
),
|
||||
new VectorChunk(
|
||||
Content: "Performance optimizations",
|
||||
EbookId: targetEbookId2.ToString(),
|
||||
Score: 0.72,
|
||||
MetadataJson: "",
|
||||
BookTitle: "Advanced C# 14",
|
||||
ChapterTitle: "Chapter 5: Span and Performance"
|
||||
)
|
||||
};
|
||||
|
||||
_vectorSearchStoreMock.Setup(v => v.SearchGlobalExcludeAsync(
|
||||
chapterContent,
|
||||
tenantId,
|
||||
activeEbookId,
|
||||
2,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockChunks);
|
||||
|
||||
// User owns the second book but not the first one
|
||||
_libraryStoreMock.Setup(l => l.GetOwnedBookIdsAsync(userId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Guid> { targetEbookId2 });
|
||||
|
||||
var query = new GetContextualRecommendationsQuery(userId);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Recommendations.Should().HaveCount(2);
|
||||
|
||||
var firstRec = result.Value.Recommendations.First();
|
||||
firstRec.BookTitle.Should().Be("Clean Architecture deep dive");
|
||||
firstRec.ChapterTitle.Should().Be("Chapter 3: Result Pattern");
|
||||
firstRec.MatchPercentage.Should().Be(88);
|
||||
firstRec.IsPremiumUpsell.Should().BeTrue(); // User does not own book 1
|
||||
firstRec.TargetBookId.Should().Be(targetEbookId1);
|
||||
|
||||
var secondRec = result.Value.Recommendations.Last();
|
||||
secondRec.BookTitle.Should().Be("Advanced C# 14");
|
||||
secondRec.ChapterTitle.Should().Be("Chapter 5: Span and Performance");
|
||||
secondRec.MatchPercentage.Should().Be(72);
|
||||
secondRec.IsPremiumUpsell.Should().BeFalse(); // User owns book 2
|
||||
secondRec.TargetBookId.Should().Be(targetEbookId2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace NexusReader.Application.Tests.Services;
|
||||
|
||||
public class PaywallParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParsePaywallTrigger_WithValidSimpleToken_ReturnsTrueAndCorrectValues()
|
||||
{
|
||||
// Arrange
|
||||
var guid = Guid.NewGuid();
|
||||
var rawText = $"Teaser sentence. [PAYWALL_TRIGGER:{guid}:Clean Book Title:45:82]";
|
||||
|
||||
// Act
|
||||
var result = PaywallParser.TryParsePaywallTrigger(
|
||||
rawText,
|
||||
out var teaser,
|
||||
out var bookId,
|
||||
out var title,
|
||||
out var localScore,
|
||||
out var globalScore);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
teaser.Should().Be("Teaser sentence.");
|
||||
bookId.Should().Be(guid);
|
||||
title.Should().Be("Clean Book Title");
|
||||
localScore.Should().Be(45);
|
||||
globalScore.Should().Be(82);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParsePaywallTrigger_WithColonsInBookTitle_ReturnsTrueAndCorrectValues()
|
||||
{
|
||||
// Arrange
|
||||
var guid = Guid.NewGuid();
|
||||
var rawText = $"Teaser text. [PAYWALL_TRIGGER:{guid}:Architektura: .NET 10 i C# 14:15:99]";
|
||||
|
||||
// Act
|
||||
var result = PaywallParser.TryParsePaywallTrigger(
|
||||
rawText,
|
||||
out var teaser,
|
||||
out var bookId,
|
||||
out var title,
|
||||
out var localScore,
|
||||
out var globalScore);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
teaser.Should().Be("Teaser text.");
|
||||
bookId.Should().Be(guid);
|
||||
title.Should().Be("Architektura: .NET 10 i C# 14");
|
||||
localScore.Should().Be(15);
|
||||
globalScore.Should().Be(99);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("Just plain text with no trigger token.")]
|
||||
[InlineData("Plain text [PAYWALL_TRIGGER:invalid-guid:Title:50:80]")]
|
||||
[InlineData("Plain text [PAYWALL_TRIGGER:00000000-0000-0000-0000-000000000000:Title:50:invalid]")]
|
||||
[InlineData("Plain text [PAYWALL_TRIGGER:00000000-0000-0000-0000-000000000000:Title:invalid:80]")]
|
||||
[InlineData("Plain text [PAYWALL_TRIGGER:00000000-0000-0000-0000-000000000000:Title]")]
|
||||
public void TryParsePaywallTrigger_WithInvalidInputs_ReturnsFalse(string rawText)
|
||||
{
|
||||
// Act
|
||||
var result = PaywallParser.TryParsePaywallTrigger(
|
||||
rawText,
|
||||
out var teaser,
|
||||
out var bookId,
|
||||
out var title,
|
||||
out var localScore,
|
||||
out var globalScore);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user