From 9291bde53169ee10164f76958e1f490b80ba8583 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sun, 7 Jun 2026 16:56:36 +0000 Subject: [PATCH] style: complete Light Sepia theme overrides for user dashboard (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #79 This Pull Request completes the visual implementation of the premium **"Warm Paper / Soft Sepia"** light theme across all user dashboard sub-modules and components. ### 🎨 Styling Refactoring & Light Sepia Layout We refactored isolated component styles (`.razor.css`) to ensure proper contrast, remove hardcoded dark tokens, and fix text visibility under the `.theme-light` environment: 1. **Dashboard & Sidebar Layouts:** * **MainHubLayout.razor.css** & **Dashboard.razor.css**: Replaced hardcoded dark backgrounds with `var(--bg-surface)` and `var(--bg-base)`. Overrode user name brackets, progress bar elements, active quiz cards, graph nodes, and buttons. 2. **Catalog & Library Pages:** * **Catalog.razor.css** & **MyBooks.razor.css**: Adjusted cover hover actions, overlay transparency, and progress tracks. Fixed course tile background gradients to use a warm, elegant `#e4e1d9` layer and `var(--text-main)` code text. 3. **Profile & Settings Views:** * **Profile.razor.css** & **Settings.razor.css**: Overrode token usage progress tracks, page title gradient transparency, section descriptions, and diagnostic button styling. 4. **Concepts Dashboard & Interactive Widgets:** * **ConceptsDashboard.razor.css** & **ConceptsMap.razor.css**: Transitioned node headers, unlocked/locked badges, warning blocks, and term pills to semantic colors. Removed neon glow animations for locked nodes. 5. **Intelligence Workspace & AI Responses:** * **Intelligence.razor.css** & **AiResponseRenderer.razor.css**: Refactored empty state welcome messages, placeholder styles, input fields, and robot avatar borders. Refined the linear-gradient mask fade effect to blend correctly into the light sepia surface environment rather than dropping into dark transparent channels. 6. **Dashboard Sidebar Widgets:** * **CurrentReadingWidget.razor.css** & **ContextualRecommendationsWidget.razor.css**: Replaced hardcoded `#1a1a1e` card containers and light text colors with semantic variables (`var(--bg-surface)`, `var(--border)`, `var(--text-main)`, and `var(--text-muted)`). ### 🛠️ Blazor CSS Isolation Compiler Compliance * Avoided the use of `:global(.theme-light)` selector overrides within isolated CSS rules because they are unsupported by Blazor's CSS isolation compiler and cause the compiled stylesheet to ignore them. * Replaced them with the correct standard selector format `.theme-light .some-class` which properly compiles to `.theme-light .some-class[b-xxxx]`, applying correct theme styles recursively to child scoped markup. ### 🧪 Verification * Checked that the solution builds cleanly with 0 compiler errors: `dotnet build NexusReader.slnx --no-restore` * Ran all unit tests successfully: `dotnet test NexusReader.slnx --no-restore` --------- Co-authored-by: Marek Jasiński Reviewed-on: https://git.archimap.cloud/mjasin/Nexus.Reader/pulls/78 Co-authored-by: Antigravity Co-committed-by: Antigravity --- .../Services/IUserPreferenceStore.cs | 10 + .../Commands/User/UpdateThemeCommand.cs | 6 + .../User/UpdateThemeCommandHandler.cs | 41 + .../Common/AppJsonContext.cs | 2 + .../DTOs/User/UpdateThemeRequest.cs | 5 + .../DTOs/User/UserProfileDto.cs | 2 + .../User/GetUserProfileQueryHandler.cs | 2 + ...60607104453_AddThemePreference.Designer.cs | 711 ++++++++++++++++++ .../20260607104453_AddThemePreference.cs | 56 ++ .../Migrations/AppDbContextModelSnapshot.cs | 13 +- .../Persistence/AppDbContext.cs | 5 + src/NexusReader.Domain/Entities/NexusUser.cs | 6 + src/NexusReader.Domain/Enums/ThemeMode.cs | 8 + src/NexusReader.Maui/MauiProgram.cs | 6 +- .../Services/MauiUserPreferenceStore.cs | 61 ++ src/NexusReader.Maui/wwwroot/index.html | 29 +- .../Molecules/AiResponseRenderer.razor.css | 119 ++- .../Molecules/IntelligenceToolbar.razor | 6 +- .../Organisms/ConceptsMap.razor.css | 123 +++ .../ContextualRecommendationsWidget.razor | 10 +- .../ContextualRecommendationsWidget.razor.css | 77 ++ .../Organisms/CurrentReadingWidget.razor | 4 +- .../Organisms/CurrentReadingWidget.razor.css | 57 ++ .../Organisms/MobileReaderToolbar.razor | 2 +- .../Components/Organisms/ReaderCanvas.razor | 6 +- .../Layout/MainHubLayout.razor | 4 +- .../Layout/MainHubLayout.razor.css | 108 ++- .../Layout/ReaderLayout.razor | 6 +- .../Pages/Account/Login.razor | 9 +- .../Pages/Account/Profile.razor | 34 + .../Pages/Account/Profile.razor.css | 154 +++- .../Pages/Account/Register.razor | 9 +- .../Pages/Catalog.razor.css | 76 +- .../Pages/ConceptsDashboard.razor.css | 168 +++-- .../Pages/Dashboard.razor | 40 +- .../Pages/Dashboard.razor.css | 180 +++-- .../Pages/Intelligence.razor.css | 127 +++- .../Pages/MyBooks.razor.css | 72 +- .../Pages/Settings.razor.css | 37 + .../Services/IThemeService.cs | 7 +- .../Services/ThemeService.cs | 135 +++- src/NexusReader.UI.Shared/_Imports.razor | 1 + src/NexusReader.UI.Shared/wwwroot/app.css | 84 ++- src/NexusReader.UI.Shared/wwwroot/js/theme.js | 25 +- src/NexusReader.Web.Client/Program.cs | 41 +- .../Services/CloudUserPreferenceStore.cs | 61 ++ src/NexusReader.Web/Components/App.razor | 29 +- src/NexusReader.Web/Program.cs | 18 +- .../Services/ServerRecommendationService.cs | 50 ++ .../Services/ServerThemeService.cs | 21 + .../Services/ServerUserPreferenceStore.cs | 78 ++ 51 files changed, 2574 insertions(+), 367 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Services/IUserPreferenceStore.cs create mode 100644 src/NexusReader.Application/Commands/User/UpdateThemeCommand.cs create mode 100644 src/NexusReader.Application/Commands/User/UpdateThemeCommandHandler.cs create mode 100644 src/NexusReader.Application/DTOs/User/UpdateThemeRequest.cs create mode 100644 src/NexusReader.Data/Migrations/20260607104453_AddThemePreference.Designer.cs create mode 100644 src/NexusReader.Data/Migrations/20260607104453_AddThemePreference.cs create mode 100644 src/NexusReader.Domain/Enums/ThemeMode.cs create mode 100644 src/NexusReader.Maui/Services/MauiUserPreferenceStore.cs create mode 100644 src/NexusReader.Web.Client/Services/CloudUserPreferenceStore.cs create mode 100644 src/NexusReader.Web/Services/ServerRecommendationService.cs create mode 100644 src/NexusReader.Web/Services/ServerThemeService.cs create mode 100644 src/NexusReader.Web/Services/ServerUserPreferenceStore.cs diff --git a/src/NexusReader.Application/Abstractions/Services/IUserPreferenceStore.cs b/src/NexusReader.Application/Abstractions/Services/IUserPreferenceStore.cs new file mode 100644 index 0000000..4aa09e2 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IUserPreferenceStore.cs @@ -0,0 +1,10 @@ +using FluentResults; +using NexusReader.Domain.Enums; + +namespace NexusReader.Application.Abstractions.Services; + +public interface IUserPreferenceStore +{ + Task SaveThemePreferenceAsync(ThemeMode mode); + Task> GetThemePreferenceAsync(); +} diff --git a/src/NexusReader.Application/Commands/User/UpdateThemeCommand.cs b/src/NexusReader.Application/Commands/User/UpdateThemeCommand.cs new file mode 100644 index 0000000..145d6b9 --- /dev/null +++ b/src/NexusReader.Application/Commands/User/UpdateThemeCommand.cs @@ -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; diff --git a/src/NexusReader.Application/Commands/User/UpdateThemeCommandHandler.cs b/src/NexusReader.Application/Commands/User/UpdateThemeCommandHandler.cs new file mode 100644 index 0000000..b9d3346 --- /dev/null +++ b/src/NexusReader.Application/Commands/User/UpdateThemeCommandHandler.cs @@ -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 +{ + private readonly IDbContextFactory _dbContextFactory; + + public UpdateThemeCommandHandler(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task 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)); + } + } +} diff --git a/src/NexusReader.Application/Common/AppJsonContext.cs b/src/NexusReader.Application/Common/AppJsonContext.cs index 2265dc2..387442f 100644 --- a/src/NexusReader.Application/Common/AppJsonContext.cs +++ b/src/NexusReader.Application/Common/AppJsonContext.cs @@ -16,6 +16,8 @@ namespace NexusReader.Application.Common; [JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))] [JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(NexusReader.Application.DTOs.User.UpdateThemeRequest))] +[JsonSerializable(typeof(NexusReader.Domain.Enums.ThemeMode))] public partial class AppJsonContext : JsonSerializerContext { } diff --git a/src/NexusReader.Application/DTOs/User/UpdateThemeRequest.cs b/src/NexusReader.Application/DTOs/User/UpdateThemeRequest.cs new file mode 100644 index 0000000..f3d40bf --- /dev/null +++ b/src/NexusReader.Application/DTOs/User/UpdateThemeRequest.cs @@ -0,0 +1,5 @@ +using NexusReader.Domain.Enums; + +namespace NexusReader.Application.DTOs.User; + +public record UpdateThemeRequest(ThemeMode Mode); diff --git a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs index 31dd1d3..aa05d9a 100644 --- a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs +++ b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs @@ -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; /// /// Relational data for the current subscription plan. diff --git a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs index 82b2150..25ecdee 100644 --- a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs +++ b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs @@ -27,6 +27,7 @@ public class GetUserProfileQueryHandler : IRequestHandler q.CompletedDate).Take(5).Select(q => new QuizResultDto diff --git a/src/NexusReader.Data/Migrations/20260607104453_AddThemePreference.Designer.cs b/src/NexusReader.Data/Migrations/20260607104453_AddThemePreference.Designer.cs new file mode 100644 index 0000000..aa0c97a --- /dev/null +++ b/src/NexusReader.Data/Migrations/20260607104453_AddThemePreference.Designer.cs @@ -0,0 +1,711 @@ +// +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 + { + /// + 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("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CoverUrl") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsReadyForReading") + .HasColumnType("boolean"); + + b.Property("LastChapter") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LastChapterIndex") + .HasColumnType("integer"); + + b.Property("LastReadDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Progress") + .HasColumnType("double precision"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("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("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EbookId") + .HasColumnType("uuid"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RelationType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SourceUnitId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("AITokenLimit") + .HasColumnType("integer"); + + b.Property("AITokensUsed") + .HasColumnType("integer"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastAiActionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadPageId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionPlanId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ThemePreference") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalQuestions") + .HasColumnType("integer"); + + b.Property("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("ContentHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalText") + .IsRequired() + .HasColumnType("text"); + + b.Property("PromptVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AITokenLimit") + .HasColumnType("integer"); + + b.Property("IsUnlimitedTokens") + .HasColumnType("boolean"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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 + } + } +} diff --git a/src/NexusReader.Data/Migrations/20260607104453_AddThemePreference.cs b/src/NexusReader.Data/Migrations/20260607104453_AddThemePreference.cs new file mode 100644 index 0000000..20e1de5 --- /dev/null +++ b/src/NexusReader.Data/Migrations/20260607104453_AddThemePreference.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Pgvector; + +#nullable disable + +namespace NexusReader.Data.Migrations +{ + /// + public partial class AddThemePreference : Migration + { + /// + 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( + name: "ThemePreference", + table: "AspNetUsers", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ThemePreference", + table: "AspNetUsers"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:vector", ",,"); + + migrationBuilder.AddColumn( + name: "Vector", + table: "SemanticKnowledgeCache", + type: "vector(1536)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Vector", + table: "KnowledgeUnits", + type: "vector(768)", + nullable: true); + } + } +} diff --git a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs index e794006..05ee59d 100644 --- a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs @@ -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("Type") .HasColumnType("integer"); - b.Property("Vector") - .HasColumnType("vector(768)"); - b.Property("Version") .IsRequired() .HasMaxLength(50) @@ -388,6 +383,11 @@ namespace NexusReader.Data.Migrations .HasMaxLength(128) .HasColumnType("character varying(128)"); + b.Property("ThemePreference") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + b.Property("TwoFactorEnabled") .HasColumnType("boolean"); @@ -480,9 +480,6 @@ namespace NexusReader.Data.Migrations .HasMaxLength(128) .HasColumnType("character varying(128)"); - b.Property("Vector") - .HasColumnType("vector(1536)"); - b.HasKey("ContentHash"); b.HasIndex("ContentHash") diff --git a/src/NexusReader.Data/Persistence/AppDbContext.cs b/src/NexusReader.Data/Persistence/AppDbContext.cs index 57d80d5..0f8b3b2 100644 --- a/src/NexusReader.Data/Persistence/AppDbContext.cs +++ b/src/NexusReader.Data/Persistence/AppDbContext.cs @@ -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 // 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() + .HasDefaultValue(ThemeMode.System); }); modelBuilder.Entity(entity => diff --git a/src/NexusReader.Domain/Entities/NexusUser.cs b/src/NexusReader.Domain/Entities/NexusUser.cs index c395882..dda03eb 100644 --- a/src/NexusReader.Domain/Entities/NexusUser.cs +++ b/src/NexusReader.Domain/Entities/NexusUser.cs @@ -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. /// public DateTime? LastReadAt { get; set; } + + /// + /// User's visual theme preference. + /// + public ThemeMode ThemePreference { get; set; } = ThemeMode.System; } diff --git a/src/NexusReader.Domain/Enums/ThemeMode.cs b/src/NexusReader.Domain/Enums/ThemeMode.cs new file mode 100644 index 0000000..64c33b1 --- /dev/null +++ b/src/NexusReader.Domain/Enums/ThemeMode.cs @@ -0,0 +1,8 @@ +namespace NexusReader.Domain.Enums; + +public enum ThemeMode +{ + System = 0, + Dark = 1, + LightSepia = 2 +} diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs index 4a5fca4..f72f6d6 100644 --- a/src/NexusReader.Maui/MauiProgram.cs +++ b/src/NexusReader.Maui/MauiProgram.cs @@ -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(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Minimal Identity (Safe Mode) builder.Services.AddScoped(); @@ -67,7 +68,8 @@ public static class MauiProgram var featureSettings = builder.Configuration.GetSection("Features").Get() ?? new FeatureSettings(); builder.Services.AddSingleton(featureSettings); - builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/NexusReader.Maui/Services/MauiUserPreferenceStore.cs b/src/NexusReader.Maui/Services/MauiUserPreferenceStore.cs new file mode 100644 index 0000000..cfe0677 --- /dev/null +++ b/src/NexusReader.Maui/Services/MauiUserPreferenceStore.cs @@ -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 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> GetThemePreferenceAsync() + { + try + { + var client = CreateClient(); + var response = await client.GetAsync("identity/profile"); + if (response.IsSuccessStatusCode) + { + var profile = await response.Content.ReadFromJsonAsync(); + 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)); + } + } +} diff --git a/src/NexusReader.Maui/wwwroot/index.html b/src/NexusReader.Maui/wwwroot/index.html index 26a7ed5..8d3fbd0 100644 --- a/src/NexusReader.Maui/wwwroot/index.html +++ b/src/NexusReader.Maui/wwwroot/index.html @@ -9,13 +9,28 @@ diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css index b8d96f1..09c66a8 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css @@ -30,17 +30,17 @@ } .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); + 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 rgba(0, 255, 153, 0.4); - box-shadow: 0 0 10px rgba(0, 255, 153, 0.25); + border: 1px solid var(--accent); + box-shadow: 0 0 10px var(--accent-glow); } .message-bubble { @@ -55,23 +55,23 @@ } .user-bubble { - background: #1a1a1e; - border: 1px solid rgba(255, 255, 255, 0.05); - color: #e4e4e7; + 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.2); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02); } .ai-bubble { - background: rgba(26, 26, 30, 0.6); - border: 1px solid rgba(255, 255, 255, 0.05); - color: #e2e8f0; + 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.2); + box-shadow: 0 4px 25px rgba(0, 0, 0, 0.03); } .paywalled-bubble { - border-color: rgba(16, 185, 129, 0.15); + border-color: var(--accent-glow); } .message-header { @@ -109,8 +109,8 @@ .paywall-teaser { position: relative; margin-bottom: 1.5rem; - -webkit-mask-image: linear-gradient(to bottom, black 30%, transparent 100%); - mask-image: linear-gradient(to bottom, black 30%, transparent 100%); + -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; @@ -119,12 +119,12 @@ /* Upsell Card */ .upsell-card { - background: #1a1a1e; + background: var(--bg-base); border-radius: 12px; - border: 1px solid rgba(16, 185, 129, 0.25); + border: 1px solid var(--accent-glow); padding: 1.5rem; margin-top: 1rem; - box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08), 0 4px 12px rgba(0, 0, 0, 0.4); + 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; } @@ -141,14 +141,14 @@ .upsell-header h4 { margin: 0; - color: #10b981; + color: var(--accent); font-size: 1.1rem; font-weight: 700; letter-spacing: 0.5px; } .upsell-text { - color: rgba(255, 255, 255, 0.75); + color: var(--text-main); font-size: 0.9rem; line-height: 1.55; margin: 0 0 1.25rem 0; @@ -177,15 +177,16 @@ } .btn-primary { - background: #10b981; + background: var(--accent); border: none; - color: #121214; + color: var(--bg-surface); } .btn-primary:hover:not(:disabled) { - background: #0d9668; + background: var(--accent); + opacity: 0.9; transform: translateY(-2px); - box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3); + box-shadow: 0 4px 15px var(--accent-glow); } .btn-primary:active:not(:disabled) { @@ -193,19 +194,19 @@ } .btn-primary:disabled { - background: rgba(16, 185, 129, 0.5); - color: rgba(18, 18, 20, 0.6); + background: var(--accent-glow); + color: var(--text-muted); cursor: not-allowed; } .btn-secondary { background: transparent; - border: 1px solid #10b981; - color: #10b981; + border: 1px solid var(--accent); + color: var(--accent); } .btn-secondary:hover { - background: rgba(16, 185, 129, 0.05); + background: var(--accent-glow); transform: translateY(-2px); } @@ -218,9 +219,9 @@ display: flex; align-items: center; gap: 0.75rem; - background: rgba(16, 185, 129, 0.1); - border: 1px solid rgba(16, 185, 129, 0.3); - color: #10b981; + background: var(--accent-glow); + border: 1px solid var(--accent); + color: var(--accent); padding: 1rem; border-radius: 8px; margin-top: 1.25rem; @@ -238,8 +239,8 @@ .payment-spinner { width: 16px; height: 16px; - border: 2px solid rgba(18, 18, 20, 0.2); - border-top-color: #121214; + border: 2px solid var(--border); + border-top-color: var(--accent); border-radius: 50%; margin-right: 0.75rem; animation: spin 0.8s linear infinite; @@ -265,3 +266,49 @@ 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%); +} + diff --git a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor index 3026d26..523e8ee 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor @@ -49,7 +49,7 @@ protected override void OnInitialized() { FocusMode.OnFocusModeChanged += HandleUpdate; - ThemeService.OnThemeChanged += HandleThemeChangedAsync; + ThemeService.OnThemeChanged += HandleThemeChanged; } private async Task HandleClearCache() @@ -68,11 +68,11 @@ 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; } } diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css index 47220b5..9b69e69 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/ConceptsMap.razor.css @@ -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); +} + diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor b/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor index 89626ab..26b8a66 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor @@ -15,7 +15,7 @@ @if (_isLoading) { -
+
@@ -25,24 +25,24 @@ } else if (_hasError) { -
+

Nie udało się załadować rekomendacji.

} else if (_recommendations is null || _recommendations.Count == 0) { -
+

Zacznij czytać, aby odkryć powiązane tytuły.

} else { -
    +
      @foreach (var rec in _recommendations) { -
    • diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor.css index 4718152..a014fe9 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor.css @@ -251,3 +251,80 @@ 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; +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor b/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor index 2d493b5..c794857 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor @@ -5,7 +5,7 @@
      @if (Book != null) { -
      +
      @Book.Title
      @@ -51,7 +51,7 @@ } else { -
      +
      diff --git a/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor.css index 429b93c..bb2c941 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor.css @@ -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; +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor b/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor index 8d1bc0e..fb79ca0 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor @@ -114,7 +114,7 @@ ThemeService.OnThemeChanged += HandleThemeChanged; } - private Task HandleThemeChanged() => InvokeAsync(StateHasChanged); + private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged); private double GetDashOffset() { diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index 9467195..f179a9b 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -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; diff --git a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor index 46b1de1..62239f2 100644 --- a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor @@ -6,13 +6,13 @@ @if (!_isFullyLoaded) { -
      +
      Synchronizing Secure Session...
      } -
      +
      diff --git a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css index 3fcda6e..fb859da 100644 --- a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css +++ b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css @@ -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); + } +} diff --git a/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor b/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor index 4b6744d..1a6ce13 100644 --- a/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor @@ -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 diff --git a/src/NexusReader.UI.Shared/Pages/Account/Login.razor b/src/NexusReader.UI.Shared/Pages/Account/Login.razor index 5295a1f..a1c4e00 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Login.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Login.razor @@ -33,7 +33,7 @@ lub
      - +
      @@ -98,7 +98,7 @@
      -
      + + +
      +
      + +

      Preferencje Wizualne

      +
      +
      +

      Wybierz profil wizualny systemu zoptymalizowany dla Twojego urządzenia i warunków czytania.

      +
      + + + +
      +
      +
      } @@ -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; diff --git a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor.css b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor.css index 39af1f6..d629224 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor.css @@ -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; +} diff --git a/src/NexusReader.UI.Shared/Pages/Account/Register.razor b/src/NexusReader.UI.Shared/Pages/Account/Register.razor index b4b18bb..f66ff95 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Register.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Register.razor @@ -21,7 +21,7 @@

      Utwórz nowe konto

      - +
      @@ -71,14 +71,17 @@
      - + @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; diff --git a/src/NexusReader.UI.Shared/Pages/Catalog.razor.css b/src/NexusReader.UI.Shared/Pages/Catalog.razor.css index 976681c..1ae0296 100644 --- a/src/NexusReader.UI.Shared/Pages/Catalog.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Catalog.razor.css @@ -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; +} diff --git a/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor.css b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor.css index 53edb26..0dbd5ed 100644 --- a/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor.css +++ b/src/NexusReader.UI.Shared/Pages/ConceptsDashboard.razor.css @@ -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); +} diff --git a/src/NexusReader.UI.Shared/Pages/Dashboard.razor b/src/NexusReader.UI.Shared/Pages/Dashboard.razor index 4808e74..76be0eb 100644 --- a/src/NexusReader.UI.Shared/Pages/Dashboard.razor +++ b/src/NexusReader.UI.Shared/Pages/Dashboard.razor @@ -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; -
      -
      - } +
      + @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; +
      +
      + } +
      } else { -
      -
      -
      +
      +
      +
      +
      +
      }
      @@ -111,10 +115,10 @@
      @if (_profile?.RecentQuizzes != null && _profile.RecentQuizzes.Any()) { -
      +
      @foreach (var quiz in _profile.RecentQuizzes) { -
      +
      @quiz.Topic = 50 ? "badge-warning" : "badge-danger")"> @@ -130,7 +134,7 @@ } else { -
      +

      Brak rozwiązanych quizów

      Rozwiązuj quizy w trakcie czytania książek, aby śledzić swoje postępy.

      diff --git a/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css b/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css index 7794270..29e079a 100644 --- a/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css @@ -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); +} + + diff --git a/src/NexusReader.UI.Shared/Pages/Intelligence.razor.css b/src/NexusReader.UI.Shared/Pages/Intelligence.razor.css index faf7c44..4d78412 100644 --- a/src/NexusReader.UI.Shared/Pages/Intelligence.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Intelligence.razor.css @@ -1,7 +1,7 @@ .intelligence-page { margin: -2.5rem; height: 100vh; - background: #121214; + background: var(--bg-base); display: flex; flex-direction: column; overflow: hidden; @@ -45,11 +45,11 @@ background: transparent; } .chat-thread-container::-webkit-scrollbar-thumb { - background: rgba(16, 185, 129, 0.2); + background: var(--accent-glow); border-radius: 4px; } .chat-thread-container::-webkit-scrollbar-thumb:hover { - background: rgba(16, 185, 129, 0.4); + background: var(--accent); } .chat-bubbles-scroll { @@ -78,7 +78,7 @@ .welcome-prompt { font-family: var(--nexus-font-sans, inherit); - color: #e4e4e7; + color: var(--text-main); font-size: 1.35rem; font-weight: 500; letter-spacing: -0.2px; @@ -87,7 +87,7 @@ /* Input Controls */ .chat-input-controls { padding: 1.5rem 4rem 3rem 4rem; - background: linear-gradient(to top, #121214 70%, rgba(18, 18, 20, 0)); + background: linear-gradient(to top, var(--bg-base) 70%, transparent); flex-shrink: 0; } @@ -117,13 +117,13 @@ gap: 0.6rem; font-size: 0.85rem; font-weight: 500; - color: #8b8273; + color: var(--text-muted); } .nexus-select { - background: #1a1a1e; - border: 1px solid rgba(255, 255, 255, 0.06); - color: #e4e4e7; + 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; @@ -138,38 +138,38 @@ } .nexus-select:focus { - border-color: #10b981; - box-shadow: 0 0 8px rgba(16, 185, 129, 0.15); + border-color: var(--accent); + box-shadow: 0 0 8px var(--accent-glow); } .input-field-group { display: flex; - background: #1a1a1e; - border: 1px solid rgba(255, 255, 255, 0.06); + 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.2); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); } .input-field-group:focus-within { - border-color: rgba(16, 185, 129, 0.5); - background: #1a1a1e; - box-shadow: 0 10px 35px rgba(16, 185, 129, 0.1); + 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; + color: var(--text-main); font-size: 0.975rem; outline: none; padding: 0.5rem 1rem; } .nexus-input::placeholder { - color: #8b8273; + color: var(--text-muted); } .search-btn { @@ -180,29 +180,31 @@ align-items: center; justify-content: center; border-radius: 8px; - background: #10b981; + background: var(--accent); border: none; - color: #121214; + color: var(--bg-surface); cursor: pointer; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); } .search-btn:hover:not(:disabled) { - background: #0d9668; + background: var(--accent); + opacity: 0.9; transform: scale(1.02); } .search-btn:disabled { - background: rgba(26, 26, 30, 0.8); - color: rgba(255, 255, 255, 0.2); - border: 1px solid rgba(255, 255, 255, 0.02); + 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: rgba(16, 185, 129, 0.25); - background: rgba(16, 185, 129, 0.03); + border-color: var(--accent-glow); + background: var(--accent-glow); max-width: 450px; } @@ -216,7 +218,7 @@ .typing-indicator span { width: 7px; height: 7px; - background: #10b981; + background: var(--accent); border-radius: 50%; display: inline-block; animation: typing-bounce 1.4s infinite ease-in-out both; @@ -227,16 +229,16 @@ .loading-label { font-size: 0.825rem; - color: rgba(255, 255, 255, 0.45); + color: var(--text-muted); font-style: italic; } .btn-spinner { width: 18px; height: 18px; - border: 2px solid rgba(18, 18, 20, 0.1); + border: 2px solid var(--border); border-radius: 50%; - border-top-color: #121214; + border-top-color: var(--accent); animation: spin 0.8s linear infinite; } @@ -260,3 +262,66 @@ 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); +} + diff --git a/src/NexusReader.UI.Shared/Pages/MyBooks.razor.css b/src/NexusReader.UI.Shared/Pages/MyBooks.razor.css index 3f3b0d6..4b089b4 100644 --- a/src/NexusReader.UI.Shared/Pages/MyBooks.razor.css +++ b/src/NexusReader.UI.Shared/Pages/MyBooks.razor.css @@ -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; +} diff --git a/src/NexusReader.UI.Shared/Pages/Settings.razor.css b/src/NexusReader.UI.Shared/Pages/Settings.razor.css index 7df77b0..08911a0 100644 --- a/src/NexusReader.UI.Shared/Pages/Settings.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Settings.razor.css @@ -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); +} + diff --git a/src/NexusReader.UI.Shared/Services/IThemeService.cs b/src/NexusReader.UI.Shared/Services/IThemeService.cs index 5b75973..ca0abe4 100644 --- a/src/NexusReader.UI.Shared/Services/IThemeService.cs +++ b/src/NexusReader.UI.Shared/Services/IThemeService.cs @@ -1,9 +1,14 @@ +using NexusReader.Domain.Enums; + namespace NexusReader.UI.Shared.Services; public interface IThemeService { + ThemeMode Mode { get; } bool IsLightMode { get; } - event Func? OnThemeChanged; + event Action? OnThemeChanged; + Task InitializeAsync(); + Task SetThemeAsync(ThemeMode mode); Task ToggleTheme(); } diff --git a/src/NexusReader.UI.Shared/Services/ThemeService.cs b/src/NexusReader.UI.Shared/Services/ThemeService.cs index 5778ed3..a538477 100644 --- a/src/NexusReader.UI.Shared/Services/ThemeService.cs +++ b/src/NexusReader.UI.Shared/Services/ThemeService.cs @@ -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? 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? 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("themeInterop.isLightMode"); - if (OnThemeChanged != null) await OnThemeChanged(); + if (_isInitialized) return; + + ThemeMode localMode = ThemeMode.System; + try + { + var cachedThemeVal = await _jsRuntime.InvokeAsync("localStorage.getItem", "theme-mode"); + if (Enum.TryParse(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("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(); } } diff --git a/src/NexusReader.UI.Shared/_Imports.razor b/src/NexusReader.UI.Shared/_Imports.razor index 7f509a6..e8f6d92 100644 --- a/src/NexusReader.UI.Shared/_Imports.razor +++ b/src/NexusReader.UI.Shared/_Imports.razor @@ -21,3 +21,4 @@ @using NexusReader.Application.DTOs.User @using NexusReader.Application.Queries.Reader @using NexusReader.Application.Queries.Recommendations +@using NexusReader.Domain.Enums diff --git a/src/NexusReader.UI.Shared/wwwroot/app.css b/src/NexusReader.UI.Shared/wwwroot/app.css index b93e6a0..81690bc 100644 --- a/src/NexusReader.UI.Shared/wwwroot/app.css +++ b/src/NexusReader.UI.Shared/wwwroot/app.css @@ -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%); diff --git a/src/NexusReader.UI.Shared/wwwroot/js/theme.js b/src/NexusReader.UI.Shared/wwwroot/js/theme.js index 0c1af2c..6485a4c 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/theme.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/theme.js @@ -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); } }; diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs index dec9980..13666ce 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -17,7 +17,8 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args); // Platform & UI Services builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Feature settings (avoiding direct raw IConfiguration injection in client pages) var featureSettings = builder.Configuration.GetSection("Features").Get() ?? new FeatureSettings(); builder.Services.AddSingleton(featureSettings); @@ -61,6 +62,10 @@ builder.Services.AddSingleton(new ThrowingQuizResultRepos builder.Services.AddSingleton(new ThrowingConceptsMapReadRepository()); builder.Services.AddSingleton(new ThrowingSyncBroadcaster()); builder.Services.AddSingleton(new ThrowingEpubExtractor()); +builder.Services.AddSingleton(new ThrowingUserLibraryStore()); +builder.Services.AddSingleton(new ThrowingVectorSearchStore()); +builder.Services.Configure(builder.Configuration.GetSection(NexusReader.Application.Common.RagMonetizationOptions.SectionName)); +builder.Services.AddSingleton(new ThrowingChatClient()); builder.Services.AddApplication(); builder.Services.AddScoped(); @@ -134,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> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default) + => throw new NotSupportedException("UserLibrary operations are not supported in the WASM client."); + + public Task> GetBookTitlesAsync(List bookIds, CancellationToken cancellationToken = default) + => throw new NotSupportedException("UserLibrary operations are not supported in the WASM client."); +} + +public class ThrowingVectorSearchStore : IVectorSearchStore +{ + public Task> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default) + => throw new NotSupportedException("VectorSearch operations are not supported in the WASM client."); + + public Task> SearchLocalAsync(string queryText, string tenantId, List whitelistedBookIds, int limit, CancellationToken cancellationToken = default) + => throw new NotSupportedException("VectorSearch operations are not supported in the WASM client."); + + public Task> 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 GetResponseAsync(IEnumerable chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Chat operations are not supported in the WASM client."); + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable 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; +} + diff --git a/src/NexusReader.Web.Client/Services/CloudUserPreferenceStore.cs b/src/NexusReader.Web.Client/Services/CloudUserPreferenceStore.cs new file mode 100644 index 0000000..e3a6960 --- /dev/null +++ b/src/NexusReader.Web.Client/Services/CloudUserPreferenceStore.cs @@ -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 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> GetThemePreferenceAsync() + { + try + { + var client = CreateClient(); + var response = await client.GetAsync("identity/profile"); + if (response.IsSuccessStatusCode) + { + var profile = await response.Content.ReadFromJsonAsync(); + 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)); + } + } +} diff --git a/src/NexusReader.Web/Components/App.razor b/src/NexusReader.Web/Components/App.razor index 55e6932..f098479 100644 --- a/src/NexusReader.Web/Components/App.razor +++ b/src/NexusReader.Web/Components/App.razor @@ -11,13 +11,28 @@ diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index e812942..dfe8d42 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -52,7 +52,9 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Feature settings (avoiding direct raw IConfiguration injection in client pages) var featureSettings = builder.Configuration.GetSection("Features").Get() ?? new FeatureSettings(); builder.Services.AddSingleton(featureSettings); @@ -753,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() .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() diff --git a/src/NexusReader.Web/Services/ServerRecommendationService.cs b/src/NexusReader.Web/Services/ServerRecommendationService.cs new file mode 100644 index 0000000..0724ba1 --- /dev/null +++ b/src/NexusReader.Web/Services/ServerRecommendationService.cs @@ -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; + +/// +/// Server-side implementation of that executes +/// the MediatR query directly inside the Web Server's request context. +/// +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?> GetRecommendationsAsync(CancellationToken cancellationToken = default) + { + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext?.User == null) + { + return new List(); + } + + var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + { + return new List(); + } + + var result = await _mediator.Send(new GetContextualRecommendationsQuery(userId), cancellationToken); + if (result.IsSuccess && result.Value != null) + { + return result.Value.Recommendations; + } + + return new List(); + } +} diff --git a/src/NexusReader.Web/Services/ServerThemeService.cs b/src/NexusReader.Web/Services/ServerThemeService.cs new file mode 100644 index 0000000..11df3ef --- /dev/null +++ b/src/NexusReader.Web/Services/ServerThemeService.cs @@ -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? OnThemeChanged + { + add { } + remove { } + } + + public Task InitializeAsync() => Task.CompletedTask; + public Task SetThemeAsync(ThemeMode mode) => Task.CompletedTask; + public Task ToggleTheme() => Task.CompletedTask; +} diff --git a/src/NexusReader.Web/Services/ServerUserPreferenceStore.cs b/src/NexusReader.Web/Services/ServerUserPreferenceStore.cs new file mode 100644 index 0000000..7c618a6 --- /dev/null +++ b/src/NexusReader.Web/Services/ServerUserPreferenceStore.cs @@ -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 _dbContextFactory; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ServerUserPreferenceStore( + IDbContextFactory dbContextFactory, + IHttpContextAccessor httpContextAccessor) + { + _dbContextFactory = dbContextFactory; + _httpContextAccessor = httpContextAccessor; + } + + public async Task 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> 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)); + } + } +}