Initial commit: NexusArchitect Professional Workstation Overhaul
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
public interface ICommand : IRequest<Result>
|
||||
{
|
||||
}
|
||||
|
||||
public interface ICommand<TResponse> : IRequest<Result<TResponse>>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
|
||||
where TCommand : ICommand
|
||||
{
|
||||
}
|
||||
|
||||
public interface ICommandHandler<TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
|
||||
where TCommand : ICommand<TResponse>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
|
||||
where TQuery : IQuery<TResponse>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Queries.Quiz;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface IAiGenerateQuizService
|
||||
{
|
||||
Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FluentResults;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface INativeStorageService
|
||||
{
|
||||
Result SaveString(string key, string value);
|
||||
Result<string?> GetString(string key);
|
||||
Result SaveBool(string key, bool value);
|
||||
Result<bool> GetBool(string key, bool defaultValue = false);
|
||||
Result Remove(string key);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using FluentResults;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface IPlatformService
|
||||
{
|
||||
Task<Result> VibrateSuccessAsync();
|
||||
Task<Result> VibrateErrorAsync();
|
||||
Task<Result> VibrateAsync(int milliseconds);
|
||||
Result<DeviceContext> GetDeviceContext();
|
||||
}
|
||||
|
||||
public record DeviceContext(
|
||||
string Model,
|
||||
string Manufacturer,
|
||||
DeviceType DeviceType,
|
||||
DisplayOrientation Orientation
|
||||
);
|
||||
|
||||
public enum DeviceType
|
||||
{
|
||||
Unknown,
|
||||
Phone,
|
||||
Tablet,
|
||||
Desktop
|
||||
}
|
||||
|
||||
public enum DisplayOrientation
|
||||
{
|
||||
Unknown,
|
||||
Portrait,
|
||||
Landscape
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Commands.Quiz;
|
||||
|
||||
public record SubmitAnswerCommand(int SelectedIndex, int CorrectIndex) : ICommand;
|
||||
@@ -0,0 +1,26 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Application.Commands.Quiz;
|
||||
|
||||
internal sealed class SubmitAnswerCommandHandler : ICommandHandler<SubmitAnswerCommand>
|
||||
{
|
||||
private readonly IPlatformService _platformService;
|
||||
|
||||
public SubmitAnswerCommandHandler(IPlatformService platformService)
|
||||
{
|
||||
_platformService = platformService;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(SubmitAnswerCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.SelectedIndex == request.CorrectIndex)
|
||||
{
|
||||
await _platformService.VibrateAsync(50);
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
return Result.Fail("Incorrect answer.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using NexusReader.Application.Queries.Graph;
|
||||
|
||||
namespace NexusReader.Application.Common;
|
||||
|
||||
[JsonSourceGenerationOptions(WriteIndented = true)]
|
||||
[JsonSerializable(typeof(GraphNodeDto))]
|
||||
[JsonSerializable(typeof(GraphLinkDto))]
|
||||
[JsonSerializable(typeof(GraphDataDto))]
|
||||
[JsonSerializable(typeof(List<GraphNodeDto>))]
|
||||
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
||||
public partial class AppJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NexusReader.Application.Mappings;
|
||||
|
||||
namespace NexusReader.Application;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddMapsterConfiguration();
|
||||
|
||||
services.AddMediatR(config =>
|
||||
{
|
||||
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Mapster;
|
||||
using MapsterMapper;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Reflection;
|
||||
|
||||
namespace NexusReader.Application.Mappings;
|
||||
|
||||
public static class MappingConfig
|
||||
{
|
||||
public static IServiceCollection AddMapsterConfiguration(this IServiceCollection services)
|
||||
{
|
||||
var config = TypeAdapterConfig.GlobalSettings;
|
||||
|
||||
// Manual registration for AOT (or use Source Generator)
|
||||
// config.NewConfig<Source, Destination>();
|
||||
|
||||
services.AddSingleton(config);
|
||||
services.AddScoped<IMapper, ServiceMapper>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Domain\NexusReader.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentResults" Version="4.0.0" />
|
||||
<PackageReference Include="Mapster" Version="10.0.7" />
|
||||
<PackageReference Include="Mapster.DependencyInjection" Version="10.0.7" />
|
||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Queries.Graph;
|
||||
|
||||
public record GetKnowledgeGraphQuery : IQuery<GraphDataDto>;
|
||||
@@ -0,0 +1,30 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Queries.Graph;
|
||||
|
||||
internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledgeGraphQuery, GraphDataDto>
|
||||
{
|
||||
public Task<Result<GraphDataDto>> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var nodes = new List<GraphNodeDto>
|
||||
{
|
||||
new("renesans-intro", "Renesans", "Concept"),
|
||||
new("florencja", "Florencja", "Location"),
|
||||
new("medyceusze", "Medyceusze", "Entity"),
|
||||
new("da-vinci-ai", "Leonardo da Vinci", "Person"),
|
||||
new("humanizm", "Humanizm", "Concept")
|
||||
};
|
||||
|
||||
var links = new List<GraphLinkDto>
|
||||
{
|
||||
new("renesans-intro", "florencja", 1),
|
||||
new("florencja", "medyceusze", 2),
|
||||
new("medyceusze", "da-vinci-ai", 3),
|
||||
new("renesans-intro", "humanizm", 1),
|
||||
new("da-vinci-ai", "humanizm", 2)
|
||||
};
|
||||
|
||||
return Task.FromResult(Result.Ok(new GraphDataDto(nodes, links)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace NexusReader.Application.Queries.Graph;
|
||||
|
||||
public record GraphNodeDto(string Id, string Label, string Group);
|
||||
public record GraphLinkDto(string Source, string Target, int Value);
|
||||
public record GraphDataDto(List<GraphNodeDto> Nodes, List<GraphLinkDto> Links);
|
||||
@@ -0,0 +1,5 @@
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Queries.Quiz;
|
||||
|
||||
public record GetQuizQuestionsQuery(string ContextBlockId) : IQuery<QuizDto>;
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Application.Queries.Quiz;
|
||||
|
||||
internal sealed class GetQuizQuestionsQueryHandler : IQueryHandler<GetQuizQuestionsQuery, QuizDto>
|
||||
{
|
||||
private readonly IAiGenerateQuizService _aiService;
|
||||
|
||||
public GetQuizQuestionsQueryHandler(IAiGenerateQuizService aiService)
|
||||
{
|
||||
_aiService = aiService;
|
||||
}
|
||||
|
||||
public async Task<Result<QuizDto>> Handle(GetQuizQuestionsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _aiService.GenerateQuizAsync(request.ContextBlockId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace NexusReader.Application.Queries.Quiz;
|
||||
|
||||
public record QuizQuestionDto(string Question, List<string> Options, int CorrectIndex);
|
||||
public record QuizDto(List<QuizQuestionDto> Questions);
|
||||
@@ -0,0 +1,5 @@
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Queries.Reader;
|
||||
|
||||
public record GetReaderPageQuery : IQuery<ReaderPageViewModel>;
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Queries.Reader;
|
||||
|
||||
internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel>
|
||||
{
|
||||
public Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var blocks = new List<ContentBlock>
|
||||
{
|
||||
new TextSegmentBlock("renesans-intro", "Renesans, nazywany również odrodzeniem, to epoka w historii kultury europejskiej, która zapoczątkowała odejście od średniowiecznego teocentryzmu na rzecz humanizmu. Narodził się we Włoszech, a dokładnie we Florencji, w XV wieku, skąd promieniował na całą Europę."),
|
||||
new TextSegmentBlock("medyceusze", "Głównym mecenasem sztuki i nauki we Florencji był potężny ród Medyceuszy. To dzięki ich wsparciu miasto stało się kolebką nowożytnej myśli, gromadząc wokół siebie najwybitniejsze umysły tamtych czasów."),
|
||||
new AiActionTriggerBlock("da-vinci-ai", "Leonardo da Vinci był jednym z najważniejszych twórców tego okresu. Czy chciałbyś dowiedzieć się więcej o jego najważniejszych wynalazkach, czy wolisz sprawdzić swoją dotychczasową wiedzę?", new List<string> { "Pokaż więcej", "Rozwiąż quiz" }),
|
||||
new TextSegmentBlock("leonardo-detail", "Człowiek renesansu, uosabiany właśnie przez Leonarda, był wszechstronnie wykształcony. Interesował się sztuką, inżynierią, anatomią i filozofią, stawiając jednostkę w centrum wszechświata.")
|
||||
};
|
||||
|
||||
return Task.FromResult(Result.Ok(new ReaderPageViewModel(blocks)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace NexusReader.Application.Queries.Reader;
|
||||
|
||||
public abstract record ContentBlock(string Id);
|
||||
public record TextSegmentBlock(string Id, string Content) : ContentBlock(Id);
|
||||
public record AiActionTriggerBlock(string Id, string Dialogue, List<string> ActionOptions) : ContentBlock(Id);
|
||||
|
||||
public record ReaderPageViewModel(List<ContentBlock> Blocks);
|
||||
@@ -0,0 +1,5 @@
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Queries.System;
|
||||
|
||||
public record GetInitializationStatusQuery : IQuery<string>;
|
||||
@@ -0,0 +1,12 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Queries.System;
|
||||
|
||||
internal sealed class GetInitializationStatusQueryHandler : IQueryHandler<GetInitializationStatusQuery, string>
|
||||
{
|
||||
public Task<Result<string>> Handle(GetInitializationStatusQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(Result.Ok("Nexus E-Reader Application is fully initialized and operational."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace NexusReader.Infrastructure.Mobile;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||
<UseMaui>true</UseMaui>
|
||||
<SkipValidateMauiImplicitPackageReferences>true</SkipValidateMauiImplicitPackageReferences>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,84 @@
|
||||
using FluentResults;
|
||||
using Microsoft.Maui.Devices;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Infrastructure.Mobile.Services;
|
||||
|
||||
public sealed class MauiPlatformService : IPlatformService
|
||||
{
|
||||
public async Task<Result> VibrateSuccessAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
HapticFeedback.Default.Perform(HapticFeedbackType.Click);
|
||||
await Task.Delay(100);
|
||||
HapticFeedback.Default.Perform(HapticFeedbackType.Click);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> VibrateErrorAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
HapticFeedback.Default.Perform(HapticFeedbackType.LongPress);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> VibrateAsync(int milliseconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
HapticFeedback.Default.Perform(HapticFeedbackType.Click);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Result<DeviceContext> GetDeviceContext()
|
||||
{
|
||||
try
|
||||
{
|
||||
var device = DeviceInfo.Current;
|
||||
var display = DeviceDisplay.Current.MainDisplayInfo;
|
||||
|
||||
return Result.Ok(new DeviceContext(
|
||||
device.Model,
|
||||
device.Manufacturer,
|
||||
MapDeviceType(device.Idiom),
|
||||
MapOrientation(display.Orientation)
|
||||
));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static NexusReader.Application.Abstractions.Services.DeviceType MapDeviceType(DeviceIdiom idiom)
|
||||
{
|
||||
if (idiom == DeviceIdiom.Phone) return NexusReader.Application.Abstractions.Services.DeviceType.Phone;
|
||||
if (idiom == DeviceIdiom.Tablet) return NexusReader.Application.Abstractions.Services.DeviceType.Tablet;
|
||||
if (idiom == DeviceIdiom.Desktop) return NexusReader.Application.Abstractions.Services.DeviceType.Desktop;
|
||||
return NexusReader.Application.Abstractions.Services.DeviceType.Unknown;
|
||||
}
|
||||
|
||||
private static NexusReader.Application.Abstractions.Services.DisplayOrientation MapOrientation(Microsoft.Maui.Devices.DisplayOrientation orientation) => orientation switch
|
||||
{
|
||||
Microsoft.Maui.Devices.DisplayOrientation.Portrait => NexusReader.Application.Abstractions.Services.DisplayOrientation.Portrait,
|
||||
Microsoft.Maui.Devices.DisplayOrientation.Landscape => NexusReader.Application.Abstractions.Services.DisplayOrientation.Landscape,
|
||||
_ => NexusReader.Application.Abstractions.Services.DisplayOrientation.Unknown
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using FluentResults;
|
||||
using Microsoft.Maui.Storage;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Infrastructure.Mobile.Services;
|
||||
|
||||
public sealed class MauiStorageService : INativeStorageService
|
||||
{
|
||||
public Result SaveString(string key, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Preferences.Default.Set(key, value);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Result<string?> GetString(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Result.Ok(Preferences.Default.Get(key, (string?)null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Result SaveBool(string key, bool value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Preferences.Default.Set(key, value);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Result<bool> GetBool(string key, bool defaultValue = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Result.Ok(Preferences.Default.Get(key, defaultValue));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Result Remove(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
Preferences.Default.Remove(key);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Infrastructure.Services;
|
||||
|
||||
namespace NexusReader.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Queries.Quiz;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
public sealed class FakeAiGenerateQuizService : IAiGenerateQuizService
|
||||
{
|
||||
public async Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 2000ms delay to highlight Skeleton loader visually
|
||||
await Task.Delay(2000, cancellationToken);
|
||||
|
||||
var fakeQuiz = new List<QuizQuestionDto>
|
||||
{
|
||||
new("Co było głównym centrum włoskiego Renesansu?", new List<string> { "Wenecja", "Rzym", "Florencja", "Mediolan" }, 2),
|
||||
new("Kto stanowił wpływowy ród mecenasów sztuki?", new List<string> { "Habsburgowie", "Medyceusze", "Borgiowie", "Sforzowie" }, 1),
|
||||
new("Jaką koncepcją filozoficzną charakteryzował się renesans?", new List<string> { "Teocentryzmem", "Nihilizmem", "Humanizmem", "Egzystencjalizmem" }, 2)
|
||||
};
|
||||
|
||||
return Result.Ok(new QuizDto(fakeQuiz));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version = "1.0" encoding = "UTF-8" ?>
|
||||
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:local="clr-namespace:NexusReader.Maui"
|
||||
x:Class="NexusReader.Maui.App">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
|
||||
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace NexusReader.Maui;
|
||||
|
||||
public partial class App : Microsoft.Maui.Controls.Application
|
||||
{
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
MainPage = new MainPage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using NexusReader.UI.Shared
|
||||
|
||||
<Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(NexusReader.UI.Shared.Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(NexusReader.UI.Shared.Layout.MainLayout)">
|
||||
<p role="alert">Sorry, there's nothing at this address.</p>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:local="clr-namespace:NexusReader.Maui"
|
||||
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Maui;assembly=Microsoft.AspNetCore.Components.WebView.Maui"
|
||||
x:Class="NexusReader.Maui.MainPage"
|
||||
BackgroundColor="{StaticResource PageBackgroundColor}">
|
||||
|
||||
<blazor:BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
|
||||
<blazor:BlazorWebView.RootComponents>
|
||||
<blazor:RootComponent Selector="#app" ComponentType="{x:Type local:Main}" />
|
||||
</blazor:BlazorWebView.RootComponents>
|
||||
</blazor:BlazorWebView>
|
||||
|
||||
</ContentPage>
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace NexusReader.Maui;
|
||||
|
||||
public partial class MainPage : ContentPage
|
||||
{
|
||||
public MainPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Infrastructure.Mobile.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
|
||||
namespace NexusReader.Maui;
|
||||
|
||||
public static class MauiProgram
|
||||
{
|
||||
public static MauiApp CreateMauiApp()
|
||||
{
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder
|
||||
.UseMauiApp<App>()
|
||||
.ConfigureFonts(fonts =>
|
||||
{
|
||||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
|
||||
});
|
||||
|
||||
builder.Services.AddMauiBlazorWebView();
|
||||
|
||||
#if DEBUG
|
||||
builder.Services.AddBlazorWebViewDeveloperTools();
|
||||
builder.Logging.AddDebug();
|
||||
#endif
|
||||
|
||||
// Infrastructure
|
||||
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
|
||||
builder.Services.AddSingleton<INativeStorageService, MauiStorageService>();
|
||||
|
||||
// Shared UI State
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||
<ApplicationTitle>Nexus E-Reader</ApplicationTitle>
|
||||
<ApplicationId>com.nexus.ereader</ApplicationId>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseMaui>true</UseMaui>
|
||||
<UseMauiEssentials>true</UseMauiEssentials>
|
||||
<SkipValidateMauiImplicitPackageReferences>true</SkipValidateMauiImplicitPackageReferences>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.20" />
|
||||
<PackageReference Include="Microsoft.Maui.Essentials" Version="10.0.20" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.Infrastructure.Mobile\NexusReader.Infrastructure.Mobile.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xaml-comp compile="true" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<Color x:Key="Primary">#512BD4</Color>
|
||||
<Color x:Key="Secondary">#DFD8F7</Color>
|
||||
<Color x:Key="Tertiary">#2B0B98</Color>
|
||||
|
||||
<Color x:Key="White">White</Color>
|
||||
<Color x:Key="Black">Black</Color>
|
||||
<Color x:Key="Gray100">#E1E1E1</Color>
|
||||
<Color x:Key="Gray200">#C8C8C8</Color>
|
||||
<Color x:Key="Gray300">#ACACAC</Color>
|
||||
<Color x:Key="Gray400">#919191</Color>
|
||||
<Color x:Key="Gray500">#6E6E6E</Color>
|
||||
<Color x:Key="Gray600">#404040</Color>
|
||||
<Color x:Key="Gray900">#212121</Color>
|
||||
<Color x:Key="Gray950">#121212</Color>
|
||||
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}" />
|
||||
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}" />
|
||||
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}" />
|
||||
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}" />
|
||||
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}" />
|
||||
|
||||
<Color x:Key="PageBackgroundColor">#0A0A0A</Color>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xaml-comp compile="true" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<Style TargetType="Label">
|
||||
<Setter Property="TextColor" Value="{StaticResource Gray100}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,10 @@
|
||||
@using System.Net.Http
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using NexusReader.Maui
|
||||
@using NexusReader.UI.Shared
|
||||
@using NexusReader.UI.Shared.Components
|
||||
@using NexusReader.UI.Shared.Layout
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>NexusReader.Maui</title>
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="app">Loading...</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
|
||||
<script src="_framework/blazor.webview.js" autostart="false"></script>
|
||||
<script src="_content/NexusReader.UI.Shared/js/d3.v7.min.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<button class="nexus-btn @Class" @onclick="OnClick" disabled="@Disabled" @attributes="AdditionalAttributes">
|
||||
@ChildContent
|
||||
</button>
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
[Parameter] public string Class { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<Microsoft.AspNetCore.Components.Web.MouseEventArgs> OnClick { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.nexus-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--nexus-card);
|
||||
color: var(--nexus-neon);
|
||||
border: 1px solid var(--nexus-neon);
|
||||
font-family: var(--nexus-font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 0 5px rgba(0, 255, 153, 0.1);
|
||||
}
|
||||
|
||||
.nexus-btn:hover:not(:disabled) {
|
||||
background-color: rgba(0, 255, 153, 0.1);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.3);
|
||||
}
|
||||
|
||||
.nexus-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.nexus-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
border-color: #555;
|
||||
color: #555;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<svg class="nexus-icon @Class" viewBox="0 0 24 24" fill="currentColor" width="@Size" height="@Size" @attributes="AdditionalAttributes">
|
||||
@switch (Name.ToLowerInvariant())
|
||||
{
|
||||
case "robot":
|
||||
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2V9c0-1.1.9-2 2-2h5V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM8 11v4h8v-4H8zm-2 0H4v4h2v-4zm14 0h-2v4h2v-4z" />
|
||||
break;
|
||||
case "play":
|
||||
<path d="M8 5v14l11-7z" />
|
||||
break;
|
||||
case "check":
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
break;
|
||||
case "search":
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||
break;
|
||||
case "message-square":
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
break;
|
||||
case "settings":
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" />
|
||||
break;
|
||||
case "bookmark":
|
||||
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
|
||||
break;
|
||||
case "target":
|
||||
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" />
|
||||
break;
|
||||
default:
|
||||
<!-- Fallback circle -->
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
break;
|
||||
}
|
||||
</svg>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Name { get; set; } = string.Empty;
|
||||
[Parameter] public string Size { get; set; } = "24";
|
||||
[Parameter] public string Class { get; set; } = string.Empty;
|
||||
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,10 @@
|
||||
.nexus-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
transition: fill 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
|
||||
.neon-glow {
|
||||
fill: var(--nexus-neon);
|
||||
filter: drop-shadow(0 0 4px var(--nexus-neon));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<div class="nexus-typography @VariantCssClass @Class" @attributes="AdditionalAttributes">
|
||||
@ChildContent
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
[Parameter] public string Class { get; set; } = string.Empty;
|
||||
[Parameter] public TypographyVariant Variant { get; set; } = TypographyVariant.UI;
|
||||
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
|
||||
private string VariantCssClass => Variant switch
|
||||
{
|
||||
TypographyVariant.Heading => "nexus-heading",
|
||||
TypographyVariant.Ebook => "nexus-ebook",
|
||||
TypographyVariant.UI => "nexus-ui",
|
||||
_ => "nexus-ui"
|
||||
};
|
||||
|
||||
public enum TypographyVariant
|
||||
{
|
||||
Heading,
|
||||
Ebook,
|
||||
UI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.nexus-typography {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nexus-heading {
|
||||
font-family: var(--nexus-font-sans);
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nexus-ebook {
|
||||
font-family: var(--nexus-font-serif);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.65;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
|
||||
.nexus-ui {
|
||||
font-family: var(--nexus-font-sans);
|
||||
font-size: 1rem;
|
||||
color: #cccccc;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@inject IQuizStateService QuizState
|
||||
|
||||
<div class="ai-bubble-container">
|
||||
<div class="ai-bubble">
|
||||
<div class="ai-avatar">
|
||||
<div class="avatar-ring"></div>
|
||||
<NexusIcon Name="robot" Size="48" Class="neon-pulse" />
|
||||
<div class="avatar-label">
|
||||
<span class="name">E-Czytnik</span>
|
||||
<span class="role">Asystent AI</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-content">
|
||||
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@Dialogue</NexusTypography>
|
||||
|
||||
<div class="ai-actions">
|
||||
<button class="action-btn ghost" @onclick='() => HandleActionClick("more")'>Pokaż więcej informacji</button>
|
||||
<button class="action-btn neon-border" @onclick='() => HandleActionClick("quiz")'>Rozwiąż quiz</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="bubble-pointer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@code {
|
||||
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
|
||||
[Parameter] public string Dialogue { get; set; } = string.Empty;
|
||||
[Parameter] public List<string> Actions { get; set; } = new();
|
||||
[Parameter] public EventCallback<string> OnActionTriggered { get; set; }
|
||||
|
||||
private bool _isQuizMode = false;
|
||||
|
||||
private async Task HandleActionClick(string action)
|
||||
{
|
||||
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_isQuizMode = true;
|
||||
QuizState.RequestQuiz(ContextBlockId);
|
||||
}
|
||||
|
||||
if (OnActionTriggered.HasDelegate)
|
||||
{
|
||||
await OnActionTriggered.InvokeAsync(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.ai-bubble-container {
|
||||
margin: 2rem 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ai-bubble {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(18, 18, 18, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
max-width: 600px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ai-avatar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.avatar-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-label .name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.avatar-label .role {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.neon-pulse {
|
||||
color: var(--nexus-neon);
|
||||
filter: drop-shadow(0 0 8px var(--nexus-neon));
|
||||
animation: pulse 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); filter: drop-shadow(0 0 8px var(--nexus-neon)); }
|
||||
50% { transform: scale(1.05); filter: drop-shadow(0 0 15px var(--nexus-neon)); }
|
||||
100% { transform: scale(1); filter: drop-shadow(0 0 8px var(--nexus-neon)); }
|
||||
}
|
||||
|
||||
.ai-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ai-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-family: var(--nexus-font-sans);
|
||||
}
|
||||
|
||||
.action-btn.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.action-btn.neon-border {
|
||||
background: rgba(0, 255, 153, 0.1);
|
||||
border: 1px solid var(--nexus-neon);
|
||||
color: var(--nexus-neon);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 255, 153, 0.2);
|
||||
}
|
||||
|
||||
.bubble-pointer {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 10px solid transparent;
|
||||
border-bottom: 10px solid transparent;
|
||||
border-left: 10px solid rgba(18, 18, 18, 0.95);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@inject IFocusModeService FocusMode
|
||||
|
||||
<aside class="intelligence-toolbar">
|
||||
<div class="toolbar-top">
|
||||
<button class="toolbar-item" title="Back">
|
||||
<NexusIcon Name="play" Size="20" Class="rotate-180" />
|
||||
</button>
|
||||
<button class="toolbar-item active" title="Chat">
|
||||
<NexusIcon Name="message-square" Size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-middle">
|
||||
<button class="toolbar-item" title="Settings">
|
||||
<NexusIcon Name="settings" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item" title="Bookmarks">
|
||||
<NexusIcon Name="bookmark" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item" title="Search">
|
||||
<NexusIcon Name="search" Size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-bottom">
|
||||
<button class="toolbar-item @(FocusMode.IsFocusModeActive ? "active focus-active" : "")"
|
||||
@onclick="FocusMode.ToggleAsync" title="Focus Mode (F)">
|
||||
<NexusIcon Name="target" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item" title="Global Settings">
|
||||
<NexusIcon Name="settings" Size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged += StateHasChanged;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged -= StateHasChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.intelligence-toolbar {
|
||||
width: 50px;
|
||||
height: 100%;
|
||||
background: #1a1a1a;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0;
|
||||
align-items: center;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.toolbar-top, .toolbar-middle, .toolbar-bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.toolbar-item {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toolbar-item:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.toolbar-item.active {
|
||||
color: var(--nexus-neon);
|
||||
}
|
||||
|
||||
.toolbar-item.focus-active {
|
||||
filter: drop-shadow(0 0 5px var(--nexus-neon));
|
||||
}
|
||||
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
@using MediatR
|
||||
@using NexusReader.Application.Queries.Quiz
|
||||
@using NexusReader.Application.Commands.Quiz
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@inject IMediator Mediator
|
||||
@inject IPlatformService PlatformService
|
||||
|
||||
<div class="knowledge-check">
|
||||
<div class="quiz-header">
|
||||
<span class="header-title">Sprawdzian Wiedzy</span>
|
||||
<button class="expand-btn">⌵</button>
|
||||
</div>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="loading-state">Pobieranie pytań...</div>
|
||||
}
|
||||
else if (_quiz != null)
|
||||
{
|
||||
<div class="quiz-body">
|
||||
@foreach (var question in _quiz.Questions)
|
||||
{
|
||||
<div class="question-container">
|
||||
<p class="question-text">@question.Question</p>
|
||||
|
||||
<div class="options-list">
|
||||
@for (int i = 0; i < question.Options.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var letter = (char)('A' + i);
|
||||
<button class="option-item @GetOptionClass(question, index)"
|
||||
@onclick="() => SelectOptionAsync(question, index)"
|
||||
disabled="@_states.ContainsKey(question)">
|
||||
<span class="option-letter">@letter)</span>
|
||||
<span class="option-text">@question.Options[index]</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="quiz-footer">
|
||||
<button class="submit-btn" disabled="@(!AllQuestionsAnswered())">Wyślij</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@code {
|
||||
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
|
||||
|
||||
private bool _isLoading = true;
|
||||
private QuizDto? _quiz;
|
||||
|
||||
private Dictionary<QuizQuestionDto, (int SelectedIndex, bool IsCorrect)> _states = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
var query = new GetQuizQuestionsQuery(ContextBlockId);
|
||||
var result = await Mediator.Send(query);
|
||||
|
||||
if (result.IsSuccess)
|
||||
_quiz = result.Value;
|
||||
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
private async Task SelectOptionAsync(QuizQuestionDto question, int index)
|
||||
{
|
||||
if (_states.ContainsKey(question)) return;
|
||||
|
||||
// Haptic feedback
|
||||
await PlatformService.VibrateAsync(40);
|
||||
|
||||
var cmd = new SubmitAnswerCommand(index, question.CorrectIndex);
|
||||
var res = await Mediator.Send(cmd);
|
||||
|
||||
_states[question] = (index, res.IsSuccess);
|
||||
|
||||
if (res.IsSuccess)
|
||||
await PlatformService.VibrateSuccessAsync();
|
||||
else
|
||||
await PlatformService.VibrateErrorAsync();
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private bool AllQuestionsAnswered()
|
||||
{
|
||||
return _quiz != null && _states.Count == _quiz.Questions.Count;
|
||||
}
|
||||
|
||||
|
||||
private string GetBlockClass(QuizQuestionDto question)
|
||||
{
|
||||
if (!_states.TryGetValue(question, out var state)) return "";
|
||||
return state.IsCorrect ? "state-correct" : "state-incorrect";
|
||||
}
|
||||
|
||||
private string GetOptionClass(QuizQuestionDto question, int index)
|
||||
{
|
||||
if (!_states.TryGetValue(question, out var state)) return "";
|
||||
|
||||
if (state.SelectedIndex == index)
|
||||
return state.IsCorrect ? "option-correct" : "option-incorrect";
|
||||
|
||||
if (state.IsCorrect == false && question.CorrectIndex == index)
|
||||
return "option-revealed-correct";
|
||||
|
||||
return "option-faded";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.knowledge-check {
|
||||
padding: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.quiz-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
font-family: var(--nexus-font-sans);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 0.95rem;
|
||||
color: #ccc;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.options-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.option-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.option-item.selected {
|
||||
border-color: var(--nexus-neon);
|
||||
background: rgba(0, 255, 153, 0.05);
|
||||
}
|
||||
|
||||
.option-letter {
|
||||
font-weight: 600;
|
||||
color: var(--nexus-neon);
|
||||
min-width: 25px;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
font-size: 0.9rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.quiz-footer {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 0.6rem 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-btn:not(:disabled) {
|
||||
background: var(--nexus-neon);
|
||||
color: #000;
|
||||
border-color: var(--nexus-neon);
|
||||
}
|
||||
|
||||
.option-correct {
|
||||
border-color: #00ff99 !important;
|
||||
background: rgba(0, 255, 153, 0.1) !important;
|
||||
}
|
||||
|
||||
.option-incorrect {
|
||||
border-color: #ff4444 !important;
|
||||
background: rgba(255, 68, 68, 0.1) !important;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
@using MediatR
|
||||
@using NexusReader.Application.Queries.Graph
|
||||
@using Microsoft.JSInterop
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@implements IAsyncDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject IJSRuntime JS
|
||||
@inject IFocusModeService FocusMode
|
||||
|
||||
<div class="knowledge-graph-container" id="@ContainerId">
|
||||
@if (GraphData == null)
|
||||
{
|
||||
<div class="loading-state">
|
||||
<NexusIcon Name="robot" Size="48" Class="neon-glow" />
|
||||
<NexusTypography>Analyzing Chapter Nodes...</NexusTypography>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="graph-controls">
|
||||
<button class="zoom-btn" @onclick="ZoomIn" title="Zoom In">+</button>
|
||||
<button class="zoom-btn" @onclick="ZoomOut" title="Zoom Out">−</button>
|
||||
<button class="zoom-btn reset" @onclick="ZoomReset" title="Reset">⟲</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback<string> OnNodeSelected { get; set; }
|
||||
|
||||
private string ContainerId = "d3-graph-container";
|
||||
private GraphDataDto? GraphData;
|
||||
private IJSObjectReference? _module;
|
||||
private DotNetObjectReference<KnowledgeGraph>? _dotNetHelper;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged += HandleFocusSimulation;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
var result = await Mediator.Send(new GetKnowledgeGraphQuery());
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
GraphData = result.Value;
|
||||
StateHasChanged();
|
||||
await InitializeGraphAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeGraphAsync()
|
||||
{
|
||||
_module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/knowledgeGraph.js");
|
||||
_dotNetHelper = DotNetObjectReference.Create(this);
|
||||
await _module.InvokeVoidAsync("mount", ContainerId, GraphData, _dotNetHelper);
|
||||
}
|
||||
|
||||
private async Task ZoomIn() => await (_module?.InvokeVoidAsync("zoomIn") ?? ValueTask.CompletedTask);
|
||||
private async Task ZoomOut() => await (_module?.InvokeVoidAsync("zoomOut") ?? ValueTask.CompletedTask);
|
||||
private async Task ZoomReset() => await (_module?.InvokeVoidAsync("zoomReset") ?? ValueTask.CompletedTask);
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnNodeClicked(string nodeId)
|
||||
{
|
||||
if (OnNodeSelected.HasDelegate)
|
||||
{
|
||||
await OnNodeSelected.InvokeAsync(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async void HandleFocusSimulation()
|
||||
{
|
||||
if (_module == null) return;
|
||||
try
|
||||
{
|
||||
if (FocusMode.IsFocusModeActive)
|
||||
await _module.InvokeVoidAsync("pause");
|
||||
else
|
||||
await _module.InvokeVoidAsync("resume");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged -= HandleFocusSimulation;
|
||||
try
|
||||
{
|
||||
if (_module is not null)
|
||||
{
|
||||
await _module.InvokeVoidAsync("unmount", ContainerId);
|
||||
await _module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Ignored, the circuit is already closed
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Ignored, the circuit is already closed
|
||||
}
|
||||
|
||||
_dotNetHelper?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.knowledge-graph-container {
|
||||
width: 100%;
|
||||
height: 50vh;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.graph-controls {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
right: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(18, 18, 18, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.zoom-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--nexus-neon);
|
||||
border-color: var(--nexus-neon);
|
||||
}
|
||||
|
||||
.zoom-btn.reset {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
animation: pulse 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
::deep .nexus-node-active {
|
||||
stroke: var(--nexus-neon) !important;
|
||||
stroke-width: 2px !important;
|
||||
filter: drop-shadow(0 0 12px var(--nexus-neon));
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.neon-glow {
|
||||
color: var(--nexus-neon);
|
||||
filter: drop-shadow(0 0 5px var(--nexus-neon));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
@using MediatR
|
||||
@using NexusReader.Application.Queries.Reader
|
||||
@using Microsoft.JSInterop
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject IJSRuntime JS
|
||||
@inject IThemeService ThemeService
|
||||
@inject IFocusModeService FocusMode
|
||||
|
||||
<div class="reader-canvas theme-light">
|
||||
|
||||
|
||||
@if (ViewModel == null)
|
||||
{
|
||||
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@StatusMessage</NexusTypography>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="reader-flow-container">
|
||||
@foreach (var block in ViewModel.Blocks)
|
||||
{
|
||||
<div id="@block.Id" class="block-wrapper">
|
||||
@if (block is TextSegmentBlock textSegment)
|
||||
{
|
||||
<NexusTypography Variant="NexusTypography.TypographyVariant.Ebook">@textSegment.Content</NexusTypography>
|
||||
}
|
||||
else if (block is AiActionTriggerBlock aiTrigger)
|
||||
{
|
||||
<AiAssistantBubble
|
||||
ContextBlockId="@block.Id"
|
||||
Dialogue="@aiTrigger.Dialogue"
|
||||
Actions="@aiTrigger.ActionOptions"
|
||||
OnActionTriggered="HandleAiAction" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ReaderPageViewModel? ViewModel;
|
||||
private string StatusMessage = "Loading chapter...";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
ThemeService.OnThemeChanged += StateHasChanged;
|
||||
|
||||
var result = await Mediator.Send(new GetReaderPageQuery());
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
ViewModel = result.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = "Failed to load chapter content.";
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleAiAction(string action)
|
||||
{
|
||||
Console.WriteLine($"Action Triggered from Bubble: {action}");
|
||||
}
|
||||
|
||||
public async Task ScrollToNodeAsync(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ThemeService.OnThemeChanged -= StateHasChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.reader-canvas {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.reader-flow-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<footer class="reader-footer">
|
||||
<div class="footer-content">
|
||||
<div class="page-info">
|
||||
<span class="label">Postęp:</span>
|
||||
<span class="value">@Progress%</span>
|
||||
</div>
|
||||
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" style="width: @Progress%"></div>
|
||||
</div>
|
||||
|
||||
<div class="meta-info">
|
||||
<span class="time">1:30</span>
|
||||
<span class="battery">45% 🔋</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@code {
|
||||
[Parameter] public int Progress { get; set; } = 45;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.reader-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background: #F9F9F9;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-family: var(--nexus-font-sans);
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
margin: 0 2rem;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00ff99 0%, #00d4ff 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.page-info, .meta-info {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.UI.Shared.Components.Molecules
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@inject IPlatformService PlatformService
|
||||
@inject IFocusModeService FocusMode
|
||||
|
||||
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
|
||||
<div class="reader-pane">
|
||||
<main>
|
||||
@Body
|
||||
</main>
|
||||
<ReaderFooter Progress="45" />
|
||||
</div>
|
||||
|
||||
<div class="intelligence-sidebar">
|
||||
<IntelligenceToolbar />
|
||||
<div class="intelligence-content">
|
||||
<div class="intelligence-header">
|
||||
<NexusIcon Name="robot" Size="20" Class="neon-glow" />
|
||||
<span>Asystent AI i Interaktywna Mapa</span>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div class="intelligence-scroll-area">
|
||||
<KnowledgeGraph />
|
||||
<KnowledgeCheck />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string _platformClass = "platform-desktop";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged += StateHasChanged;
|
||||
|
||||
var context = PlatformService.GetDeviceContext();
|
||||
if (context.IsSuccess)
|
||||
{
|
||||
_platformClass = context.Value.DeviceType switch
|
||||
{
|
||||
DeviceType.Phone or DeviceType.Tablet => "platform-mobile",
|
||||
_ => "platform-desktop"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged -= StateHasChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
.app-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 450px;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #121212;
|
||||
transition: grid-template-columns 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
|
||||
.reader-pane {
|
||||
background: #F9F9F9;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.intelligence-sidebar {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr;
|
||||
width: 450px;
|
||||
height: 100%;
|
||||
background: #121212;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-container.focus-mode-active {
|
||||
grid-template-columns: 1fr 0px;
|
||||
}
|
||||
|
||||
.app-container.focus-mode-active .intelligence-sidebar {
|
||||
width: 0;
|
||||
border-left-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
.intelligence-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.intelligence-header {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
gap: 0.8rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-family: var(--nexus-font-sans);
|
||||
font-size: 0.9rem;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.intelligence-header .close-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.intelligence-scroll-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding-bottom: 40px; /* footer height */
|
||||
}
|
||||
|
||||
/* Platform Specifics */
|
||||
.platform-mobile .intelligence-sidebar {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 80%;
|
||||
z-index: 100;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<script type="module" src="_content/NexusReader.UI.Shared/Layout/ReconnectModal.razor.js"></script>
|
||||
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,157 @@
|
||||
.components-reconnect-first-attempt-visible,
|
||||
.components-reconnect-repeated-attempt-visible,
|
||||
.components-reconnect-failed-visible,
|
||||
.components-pause-visible,
|
||||
.components-resume-failed-visible,
|
||||
.components-rejoining-animation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-failed,
|
||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#components-reconnect-modal {
|
||||
background-color: white;
|
||||
width: 20rem;
|
||||
margin: 20vh auto;
|
||||
padding: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||
&[open]
|
||||
|
||||
{
|
||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#components-reconnect-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-slideUp {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.components-reconnect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#components-reconnect-modal p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button {
|
||||
border: 0;
|
||||
background-color: #6b9ed2;
|
||||
color: white;
|
||||
padding: 4px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:hover {
|
||||
background-color: #3b6ea2;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:active {
|
||||
background-color: #6b9ed2;
|
||||
}
|
||||
|
||||
.components-rejoining-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div {
|
||||
position: absolute;
|
||||
border: 3px solid #0087ff;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes components-rejoining-animation {
|
||||
0% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
4.9% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Set up event handlers
|
||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||
|
||||
const retryButton = document.getElementById("components-reconnect-button");
|
||||
retryButton.addEventListener("click", retry);
|
||||
|
||||
const resumeButton = document.getElementById("components-resume-button");
|
||||
resumeButton.addEventListener("click", resume);
|
||||
|
||||
function handleReconnectStateChanged(event) {
|
||||
if (event.detail.state === "show") {
|
||||
reconnectModal.showModal();
|
||||
} else if (event.detail.state === "hide") {
|
||||
reconnectModal.close();
|
||||
} else if (event.detail.state === "failed") {
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
} else if (event.detail.state === "rejected") {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
|
||||
try {
|
||||
// Reconnect will asynchronously return:
|
||||
// - true to mean success
|
||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||
const successful = await Blazor.reconnect();
|
||||
if (!successful) {
|
||||
// We have been able to reach the server, but the circuit is no longer available.
|
||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||
if (!resumeSuccessful) {
|
||||
location.reload();
|
||||
} else {
|
||||
reconnectModal.close();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We got an exception, server is currently unavailable
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
try {
|
||||
const successful = await Blazor.resumeCircuit();
|
||||
if (!successful) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function retryWhenDocumentBecomesVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
await retry();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
|
||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,78 @@
|
||||
@page "/"
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@implements IAsyncDisposable
|
||||
@inject IQuizStateService QuizState
|
||||
@inject IFocusModeService FocusMode
|
||||
@inject IJSRuntime JS
|
||||
<PageTitle>Nexus E-Reader</PageTitle>
|
||||
|
||||
<div class="home-reader-container">
|
||||
<ReaderCanvas @ref="readerCanvas" />
|
||||
</div>
|
||||
|
||||
|
||||
@code {
|
||||
private ReaderCanvas? readerCanvas;
|
||||
private string? _activeQuizBlockId;
|
||||
|
||||
private IJSObjectReference? _interopModule;
|
||||
private IJSObjectReference? _keydownHandler;
|
||||
private DotNetObjectReference<Home>? _dotNetRef;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
QuizState.OnQuizRequested += HandleQuizRequested;
|
||||
FocusMode.OnFocusModeChanged += StateHasChanged;
|
||||
await FocusMode.InitializeAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try {
|
||||
_interopModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/focusInterop.js");
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_keydownHandler = await _interopModule.InvokeAsync<IJSObjectReference>("attachKeyboardListener", _dotNetRef);
|
||||
} catch { } /* ignored dynamically */
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnFocusKeypressed()
|
||||
{
|
||||
await FocusMode.ToggleAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleNodeSelected(string nodeId)
|
||||
{
|
||||
if (readerCanvas != null)
|
||||
{
|
||||
await readerCanvas.ScrollToNodeAsync(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleQuizRequested(string blockId)
|
||||
{
|
||||
_activeQuizBlockId = blockId;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
QuizState.OnQuizRequested -= HandleQuizRequested;
|
||||
FocusMode.OnFocusModeChanged -= StateHasChanged;
|
||||
|
||||
if (_interopModule != null && _keydownHandler != null)
|
||||
{
|
||||
try {
|
||||
await _interopModule.InvokeVoidAsync("detachKeyboardListener", _keydownHandler);
|
||||
await _interopModule.DisposeAsync();
|
||||
await _keydownHandler.DisposeAsync();
|
||||
} catch { } // Circuit disconnected catch explicitly
|
||||
}
|
||||
|
||||
_dotNetRef?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.home-reader-container {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.reader-pane::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.reader-pane::-webkit-scrollbar-thumb {
|
||||
background-color: #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="@typeof(Routes).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public sealed class FocusModeService : IFocusModeService
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
public bool IsFocusModeActive { get; private set; }
|
||||
public event Action? OnFocusModeChanged;
|
||||
|
||||
public FocusModeService(IJSRuntime jsRuntime)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", "nexus_focus_mode");
|
||||
if (value == "true" && !IsFocusModeActive)
|
||||
{
|
||||
IsFocusModeActive = true;
|
||||
OnFocusModeChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored during pre-rendering or unsupported environments
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ToggleAsync()
|
||||
{
|
||||
IsFocusModeActive = !IsFocusModeActive;
|
||||
OnFocusModeChanged?.Invoke();
|
||||
|
||||
try
|
||||
{
|
||||
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "nexus_focus_mode", IsFocusModeActive ? "true" : "false");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface IFocusModeService
|
||||
{
|
||||
bool IsFocusModeActive { get; }
|
||||
event Action? OnFocusModeChanged;
|
||||
Task InitializeAsync();
|
||||
Task ToggleAsync();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface IQuizStateService
|
||||
{
|
||||
string? CurrentQuizBlockId { get; }
|
||||
event Action<string>? OnQuizRequested;
|
||||
void RequestQuiz(string blockId);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface IThemeService
|
||||
{
|
||||
bool IsLightMode { get; }
|
||||
event Action? OnThemeChanged;
|
||||
void ToggleTheme();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public sealed class QuizStateService : IQuizStateService
|
||||
{
|
||||
public string? CurrentQuizBlockId { get; private set; }
|
||||
public event Action<string>? OnQuizRequested;
|
||||
|
||||
public void RequestQuiz(string blockId)
|
||||
{
|
||||
CurrentQuizBlockId = blockId;
|
||||
OnQuizRequested?.Invoke(blockId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public sealed class ThemeService : IThemeService
|
||||
{
|
||||
public bool IsLightMode { get; private set; } = false;
|
||||
public event Action? OnThemeChanged;
|
||||
|
||||
public void ToggleTheme()
|
||||
{
|
||||
IsLightMode = !IsLightMode;
|
||||
OnThemeChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using NexusReader.UI.Shared
|
||||
@using NexusReader.UI.Shared.Layout
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@using NexusReader.UI.Shared.Components.Molecules
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@@ -0,0 +1,76 @@
|
||||
@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-bg: #121212;
|
||||
--nexus-card: #1a1a1a;
|
||||
--nexus-text: #ffffff;
|
||||
--nexus-paper: #F9F9F9;
|
||||
--nexus-font-sans: 'Inter', sans-serif;
|
||||
--nexus-font-serif: 'Merriweather', serif;
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
|
||||
.theme-light {
|
||||
--nexus-bg: var(--nexus-paper);
|
||||
--nexus-card: #ffffff;
|
||||
--nexus-text: #121212;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
background-color: var(--nexus-bg);
|
||||
color: var(--nexus-text);
|
||||
font-family: var(--nexus-font-sans);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
/* Handle Notches */
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Platform Specific Tweaks */
|
||||
.platform-mobile .nexus-button {
|
||||
min-height: var(--touch-target-size);
|
||||
min-width: var(--touch-target-size);
|
||||
font-size: 1.1rem;
|
||||
padding: 12px 24px;
|
||||
}
|
||||
|
||||
.platform-desktop .nexus-button {
|
||||
min-height: 36px;
|
||||
font-size: 0.9rem;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* D3.js Touch Optimization */
|
||||
svg {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: #b32121;
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
margin: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 378 B |
@@ -0,0 +1,6 @@
|
||||
// This is a JavaScript module that is loaded on demand. It can export any number of
|
||||
// functions, and may import other JavaScript modules if required.
|
||||
|
||||
export function showPrompt(message) {
|
||||
return prompt(message, 'Type anything here');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function attachKeyboardListener(dotNetHelper) {
|
||||
const handler = (e) => {
|
||||
// Exclude inputs, textareas, etc.
|
||||
const activeNode = document.activeElement ? document.activeElement.nodeName.toLowerCase() : '';
|
||||
if (activeNode === 'input' || activeNode === 'textarea') return;
|
||||
|
||||
if (e.key === 'f' || e.key === 'F') {
|
||||
dotNetHelper.invokeMethodAsync('OnFocusKeypressed');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handler);
|
||||
return handler;
|
||||
}
|
||||
|
||||
export function detachKeyboardListener(handler) {
|
||||
if (handler) {
|
||||
window.removeEventListener('keydown', handler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import * as d3 from 'https://esm.sh/d3@7';
|
||||
|
||||
let simulation;
|
||||
let zoomBehavior;
|
||||
let svgElement;
|
||||
|
||||
export function mount(containerId, data, dotNetHelper) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
const width = container.clientWidth || 400;
|
||||
const height = container.clientHeight || 400;
|
||||
|
||||
// Create SVG
|
||||
svgElement = d3.select(container).append("svg")
|
||||
.attr("viewBox", [0, 0, width, height])
|
||||
.attr("width", "100%")
|
||||
.attr("height", "100%");
|
||||
|
||||
// Radial gradient for Nebula effect
|
||||
const defs = svgElement.append("defs");
|
||||
const radialGradient = defs.append("radialGradient")
|
||||
.attr("id", "nebulaGlow")
|
||||
.attr("cx", "50%")
|
||||
.attr("cy", "50%")
|
||||
.attr("r", "50%");
|
||||
radialGradient.append("stop").attr("offset", "0%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 1);
|
||||
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
|
||||
|
||||
// Root Group for Zoom
|
||||
const rootGroup = svgElement.append("g").attr("class", "zoom-containment");
|
||||
|
||||
// Badge Element (TU JESTEŚ)
|
||||
const badge = rootGroup.append("g")
|
||||
.attr("class", "active-badge")
|
||||
.style("display", "none");
|
||||
|
||||
badge.append("rect")
|
||||
.attr("x", -35)
|
||||
.attr("y", -35)
|
||||
.attr("width", 70)
|
||||
.attr("height", 20)
|
||||
.attr("rx", 10)
|
||||
.attr("fill", "var(--nexus-neon)");
|
||||
|
||||
badge.append("text")
|
||||
.text("TU JESTEŚ")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", -21)
|
||||
.attr("fill", "#000")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("font-size", "0.6rem");
|
||||
|
||||
// Attach Zoom Behavior
|
||||
zoomBehavior = d3.zoom()
|
||||
.scaleExtent([0.5, 4])
|
||||
.on("zoom", (e) => rootGroup.attr("transform", e.transform));
|
||||
|
||||
// Apply zoom but disable wheel interaction
|
||||
svgElement.call(zoomBehavior)
|
||||
.on("wheel.zoom", null);
|
||||
|
||||
|
||||
// Subtle Link Distance & Charge
|
||||
simulation = d3.forceSimulation(data.nodes)
|
||||
.force("link", d3.forceLink(data.links).id(d => d.id).distance(100))
|
||||
.force("charge", d3.forceManyBody().strength(-300))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collide", d3.forceCollide().radius(40));
|
||||
|
||||
// Links
|
||||
const link = rootGroup.append("g")
|
||||
.selectAll("path")
|
||||
.data(data.links)
|
||||
.join("path")
|
||||
.attr("stroke", "rgba(255,255,255,0.1)")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", 1.5);
|
||||
|
||||
// Nodes
|
||||
const node = rootGroup.append("g")
|
||||
.selectAll("g")
|
||||
.data(data.nodes)
|
||||
.join("g")
|
||||
.style("cursor", "pointer")
|
||||
.on("click", (e, d) => {
|
||||
// Remove active state from all, add to clicked
|
||||
node.selectAll(".node-pill").classed("nexus-node-active", false);
|
||||
d3.select(e.currentTarget).select(".node-pill").classed("nexus-node-active", true);
|
||||
|
||||
// Show badge
|
||||
badge.style("display", "block").datum(d);
|
||||
|
||||
dotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
|
||||
})
|
||||
.call(drag(simulation));
|
||||
|
||||
// Outer glow for nodes
|
||||
node.append("circle")
|
||||
.attr("r", 30)
|
||||
.attr("fill", "url(#nebulaGlow)")
|
||||
.attr("opacity", d => d.id === 'root' ? 0.6 : 0.2);
|
||||
|
||||
// Pill shape
|
||||
node.append("rect")
|
||||
.attr("class", "node-pill")
|
||||
.attr("x", d => -(d.label.length * 4 + 10))
|
||||
.attr("y", -12)
|
||||
.attr("width", d => d.label.length * 8 + 20)
|
||||
.attr("height", 24)
|
||||
.attr("rx", 12)
|
||||
.attr("fill", "rgba(30, 30, 30, 0.8)")
|
||||
.attr("stroke", "rgba(255, 255, 255, 0.1)")
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Labels
|
||||
node.append("text")
|
||||
.text(d => d.label)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", 4)
|
||||
.attr("fill", "#ccc")
|
||||
.attr("font-family", "var(--nexus-font-sans)")
|
||||
.attr("font-size", "0.8rem");
|
||||
|
||||
simulation.on("tick", () => {
|
||||
link.attr("d", d => {
|
||||
const dx = d.target.x - d.source.x;
|
||||
const dy = d.target.y - d.source.y;
|
||||
const dr = Math.sqrt(dx * dx + dy * dy);
|
||||
return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
|
||||
});
|
||||
|
||||
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
|
||||
const activeData = badge.datum();
|
||||
if (activeData) {
|
||||
badge.attr("transform", `translate(${activeData.x},${activeData.y})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function drag(simulation) {
|
||||
function dragstarted(event) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
event.subject.fx = event.subject.x;
|
||||
event.subject.fy = event.subject.y;
|
||||
}
|
||||
function dragged(event) {
|
||||
event.subject.fx = event.x;
|
||||
event.subject.fy = event.y;
|
||||
}
|
||||
function dragended(event) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
event.subject.fx = null;
|
||||
event.subject.fy = null;
|
||||
}
|
||||
return d3.drag()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended);
|
||||
}
|
||||
|
||||
export function unmount(containerId) {
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
}
|
||||
const container = document.getElementById(containerId);
|
||||
if (container) {
|
||||
container.innerHTML = ''; // clear svg
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollToNode(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
export function pause() {
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export function zoomIn() {
|
||||
if (svgElement && zoomBehavior) {
|
||||
svgElement.transition().duration(300).call(zoomBehavior.scaleBy, 1.3);
|
||||
}
|
||||
}
|
||||
|
||||
export function zoomOut() {
|
||||
if (svgElement && zoomBehavior) {
|
||||
svgElement.transition().duration(300).call(zoomBehavior.scaleBy, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
export function zoomReset() {
|
||||
if (svgElement && zoomBehavior) {
|
||||
svgElement.transition().duration(500).call(zoomBehavior.transform, d3.zoomIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Web.Client.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using NexusReader.Application;
|
||||
using NexusReader.Infrastructure;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -0,0 +1,41 @@
|
||||
using FluentResults;
|
||||
using Microsoft.JSInterop;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Web.Client.Services;
|
||||
|
||||
public sealed class WebPlatformService : IPlatformService
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
|
||||
public WebPlatformService(IJSRuntime jsRuntime)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async Task<Result> VibrateSuccessAsync() => await VibrateAsync(100);
|
||||
public async Task<Result> VibrateErrorAsync() => await VibrateAsync(300);
|
||||
|
||||
public async Task<Result> VibrateAsync(int milliseconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _jsRuntime.InvokeVoidAsync("navigator.vibrate", milliseconds);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Result<DeviceContext> GetDeviceContext()
|
||||
{
|
||||
return Result.Ok(new DeviceContext(
|
||||
"Browser",
|
||||
"Web",
|
||||
DeviceType.Desktop, // Default for web, or could detect via JS
|
||||
DisplayOrientation.Unknown
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function attachKeyboardListener(dotNetHelper) {
|
||||
const handler = (e) => {
|
||||
// Exclude inputs, textareas, etc.
|
||||
const activeNode = document.activeElement ? document.activeElement.nodeName.toLowerCase() : '';
|
||||
if (activeNode === 'input' || activeNode === 'textarea') return;
|
||||
|
||||
if (e.key === 'f' || e.key === 'F') {
|
||||
dotNetHelper.invokeMethodAsync('OnFocusKeypressed');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handler);
|
||||
return handler;
|
||||
}
|
||||
|
||||
export function detachKeyboardListener(handler) {
|
||||
if (handler) {
|
||||
window.removeEventListener('keydown', handler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import * as d3 from 'https://esm.sh/d3@7';
|
||||
|
||||
let simulation;
|
||||
|
||||
export function mount(containerId, data, dotNetHelper) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
const width = container.clientWidth || 400;
|
||||
const height = container.clientHeight || 400;
|
||||
|
||||
// Create SVG
|
||||
const svg = d3.select(container).append("svg")
|
||||
.attr("viewBox", [0, 0, width, height])
|
||||
.attr("width", "100%")
|
||||
.attr("height", "100%");
|
||||
|
||||
// Radial gradient for Nebula effect
|
||||
const defs = svg.append("defs");
|
||||
const radialGradient = defs.append("radialGradient")
|
||||
.attr("id", "nebulaGlow")
|
||||
.attr("cx", "50%")
|
||||
.attr("cy", "50%")
|
||||
.attr("r", "50%");
|
||||
radialGradient.append("stop").attr("offset", "0%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 1);
|
||||
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
|
||||
|
||||
// Root Group for Zoom
|
||||
const rootGroup = svg.append("g").attr("class", "zoom-containment");
|
||||
|
||||
// Attach Zoom Behavior
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.5, 4])
|
||||
.on("zoom", (e) => rootGroup.attr("transform", e.transform));
|
||||
svg.call(zoom);
|
||||
|
||||
// Subtle Link Distance & Charge
|
||||
simulation = d3.forceSimulation(data.nodes)
|
||||
.force("link", d3.forceLink(data.links).id(d => d.id).distance(60))
|
||||
.force("charge", d3.forceManyBody().strength(-150))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collide", d3.forceCollide().radius(25));
|
||||
|
||||
// Links
|
||||
const link = rootGroup.append("g")
|
||||
.selectAll("line")
|
||||
.data(data.links)
|
||||
.join("line")
|
||||
.attr("stroke", "#444")
|
||||
.attr("stroke-opacity", 0.6)
|
||||
.attr("stroke-width", 1.5);
|
||||
|
||||
// Nodes
|
||||
const node = rootGroup.append("g")
|
||||
.selectAll("g")
|
||||
.data(data.nodes)
|
||||
.join("g")
|
||||
.style("cursor", "pointer")
|
||||
.on("click", (e, d) => {
|
||||
// Remove active state from all, add to clicked
|
||||
node.select("circle.node-core").classed("nexus-node-active", false);
|
||||
d3.select(e.currentTarget).select("circle.node-core").classed("nexus-node-active", true);
|
||||
dotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
|
||||
})
|
||||
.call(drag(simulation));
|
||||
|
||||
// Outer glow for nodes
|
||||
node.append("circle")
|
||||
.attr("r", 14)
|
||||
.attr("fill", "url(#nebulaGlow)")
|
||||
.attr("opacity", 0.4);
|
||||
|
||||
// Core circle
|
||||
node.append("circle")
|
||||
.attr("class", "node-core")
|
||||
.attr("r", 6)
|
||||
.attr("fill", "#888")
|
||||
.attr("stroke", "#222")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// Labels
|
||||
node.append("text")
|
||||
.text(d => d.label)
|
||||
.attr("x", 12)
|
||||
.attr("y", 4)
|
||||
.attr("fill", "#ccc")
|
||||
.attr("font-family", "var(--nexus-font-sans)")
|
||||
.attr("font-size", "0.75rem");
|
||||
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => d.source.x)
|
||||
.attr("y1", d => d.source.y)
|
||||
.attr("x2", d => d.target.x)
|
||||
.attr("y2", d => d.target.y);
|
||||
|
||||
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
}
|
||||
|
||||
function drag(simulation) {
|
||||
function dragstarted(event) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
event.subject.fx = event.subject.x;
|
||||
event.subject.fy = event.subject.y;
|
||||
}
|
||||
function dragged(event) {
|
||||
event.subject.fx = event.x;
|
||||
event.subject.fy = event.y;
|
||||
}
|
||||
function dragended(event) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
event.subject.fx = null;
|
||||
event.subject.fy = null;
|
||||
}
|
||||
return d3.drag()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended);
|
||||
}
|
||||
|
||||
export function unmount(containerId) {
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
}
|
||||
const container = document.getElementById(containerId);
|
||||
if (container) {
|
||||
container.innerHTML = ''; // clear svg
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollToNode(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
export function pause() {
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export function resume() {
|
||||
if (simulation) {
|
||||
// give it a gentle kick to settle if moved
|
||||
simulation.alphaTarget(0.1).restart();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
|
||||
<link rel="stylesheet" href="NexusReader.Web.styles.css" />
|
||||
<ImportMap />
|
||||
|
||||
<HeadOutlet @rendermode="InteractiveAuto" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<NexusReader.UI.Shared.Routes @rendermode="InteractiveAuto" />
|
||||
<ReconnectModal />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
@code {
|
||||
[Parameter]
|
||||
public Exception? Exception { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// You could log the exception here using a logging service.
|
||||
}
|
||||
}
|
||||
|
||||
<h3 class="text-danger">An unexpected error occurred.</h3>
|
||||
<p>Please try reloading the page. If the problem persists, contact support.</p>
|
||||
@if (Exception != null)
|
||||
{
|
||||
<details class="mt-2">
|
||||
<summary>Technical details (click to expand)</summary>
|
||||
<pre style="white-space: pre-wrap; word-break: break-all;">@Exception.ToString()</pre>
|
||||
</details>
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using NexusReader.Web
|
||||
@using NexusReader.UI.Shared
|
||||
@using NexusReader.UI.Shared.Layout
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@using NexusReader.UI.Shared.Components.Molecules
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Web.Client\NexusReader.Web.Client.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,58 @@
|
||||
using NexusReader.Web.Components;
|
||||
using NexusReader.Application;
|
||||
using NexusReader.Infrastructure;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Web.Client.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents()
|
||||
.AddInteractiveWebAssemblyComponents();
|
||||
|
||||
// Enable detailed circuit errors for Server‑Side Blazor components
|
||||
builder.Services.AddServerSideBlazor()
|
||||
.AddCircuitOptions(options =>
|
||||
{
|
||||
options.DetailedErrors = true;
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseWebAssemblyDebugging();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
app.UseHsts();
|
||||
}
|
||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddInteractiveWebAssemblyRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(NexusReader.UI.Shared._Imports).Assembly);
|
||||
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "http://localhost:5104",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "https://localhost:7131;http://localhost:5104",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@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-bg: #121212;
|
||||
--nexus-card: #1e1e1e;
|
||||
--nexus-text: #ffffff;
|
||||
--nexus-font-sans: 'Inter', sans-serif;
|
||||
--nexus-font-serif: 'Merriweather', serif;
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
--nexus-bg: #F5F5F5;
|
||||
--nexus-card: #FFFFFF;
|
||||
--nexus-text: #1A1A1A;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--nexus-bg);
|
||||
color: var(--nexus-text);
|
||||
font-family: var(--nexus-font-sans);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.darker-border-checkbox.form-check-input {
|
||||
border-color: #929292;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||
text-align: start;
|
||||
}
|
||||
Reference in New Issue
Block a user