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.Data/Persistence/AppDbContextFactory.cs b/src/NexusReader.Data/Persistence/AppDbContextFactory.cs index d1e954e..2864c6e 100644 --- a/src/NexusReader.Data/Persistence/AppDbContextFactory.cs +++ b/src/NexusReader.Data/Persistence/AppDbContextFactory.cs @@ -37,7 +37,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres"; } - optionsBuilder.UseNpgsql(connectionString); + optionsBuilder.UseNpgsql(connectionString, o => o.UseVector()); return new AppDbContext(optionsBuilder.Options); } 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.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 13801ad..1868893 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -35,12 +35,12 @@ public static class DependencyInjection if (!string.IsNullOrEmpty(pgConnectionString)) { services.AddDbContextFactory(options => - options.UseNpgsql(pgConnectionString), + options.UseNpgsql(pgConnectionString, o => o.UseVector()), ServiceLifetime.Scoped); // Also register a scoped DbContext for repositories that need it services.AddDbContext(options => - options.UseNpgsql(pgConnectionString)); + options.UseNpgsql(pgConnectionString, o => o.UseVector())); } else { 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/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/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/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/Profile.razor b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor index 9771c1c..da19a21 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor @@ -4,8 +4,10 @@ @using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Components.Atoms @attribute [Authorize] +@using NexusReader.Domain.Enums @inject IIdentityService IdentityService @inject NavigationManager NavigationManager +@inject IThemeService ThemeService
@@ -97,6 +99,31 @@
+ + +
+
+ +

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..f0b98c0 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Profile.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Account/Profile.razor.css @@ -348,3 +348,114 @@ .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: #a0aec0; + 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: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + color: #a0aec0; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.theme-option-btn:hover { + background: rgba(255, 255, 255, 0.07); + color: #ffffff; + border-color: rgba(255, 255, 255, 0.15); +} + +.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); +} + +/* Light Theme overrides for Profile settings page */ +.theme-light .profile-page-container { + background-color: var(--bg-base); + color: var(--text-main); +} + +.theme-light .username { + color: var(--text-main); +} + +.theme-light .glass-panel { + background: var(--bg-surface); + border-color: rgba(0, 0, 0, 0.06); +} + +.theme-light .glass-panel:hover { + border-color: rgba(16, 185, 129, 0.3); + background: var(--bg-surface); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.04); +} + +.theme-light .card-header h3 { + color: #718096; +} + +.theme-light .usage-values .current { + color: var(--text-main); +} + +.theme-light .last-book { + background: rgba(16, 185, 129, 0.05); + border-color: rgba(16, 185, 129, 0.15); + color: var(--text-main); +} + +.theme-light .theme-description { + color: #718096; +} + +.theme-light .theme-option-btn { + background: rgba(0, 0, 0, 0.03); + border-color: rgba(0, 0, 0, 0.08); + color: #718096; +} + +.theme-light .theme-option-btn:hover { + background: rgba(0, 0, 0, 0.06); + color: var(--text-main); + border-color: rgba(0, 0, 0, 0.15); +} + +.theme-light .theme-option-btn.active { + background: rgba(16, 185, 129, 0.08); + color: #10b981; + border-color: #10b981; + box-shadow: 0 0 15px rgba(16, 185, 129, 0.1); +} + +@media (max-width: 768px) { + .theme-options { + flex-direction: column; + } +} 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..dee9bcc 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%); @@ -133,11 +143,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..ac06c29 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); 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..f684de9 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -52,7 +52,7 @@ builder.Services.AddHttpContextAccessor(); 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 +753,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/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)); + } + } +}