Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 381f26ed3e | |||
| aa80c2ba3e | |||
| 39717725ec | |||
| 9d396570aa | |||
| d78abd0c4d | |||
| 97c1c309b1 | |||
| 5740d9126a | |||
| f902073bcb | |||
| 0a3ca77d46 |
@@ -30,14 +30,4 @@ When conducting or receiving a code review for NexusReader, ensure the implement
|
|||||||
- [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`.
|
- [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`.
|
||||||
|
|
||||||
## 6. Code Review Comments
|
## 6. Code Review Comments
|
||||||
|
- [ ] **Specific Linking**: Comments should be linked to specific code. Try to avoid general comments about the entire pull request.
|
||||||
### 6.1 Posting Comments
|
|
||||||
- [ ] **Code-Linked Comments**: Every review comment **must** be anchored to a specific file and line range using the Gitea inline comment API (`path` + `new_line_num`/`old_line_num`). Free-floating general comments are only acceptable for summary notes that cannot be attributed to a single location.
|
|
||||||
- [ ] **Severity Prefix**: Prefix each comment with its severity so the author can prioritize: `🔴 Blocking`, `🟡 Design/Architecture`, or `🟢 Minor/Suggestion`.
|
|
||||||
- [ ] **Actionable Guidance**: Each comment must include a concrete, actionable suggestion — not just a description of the problem. Where applicable, provide a corrected code snippet.
|
|
||||||
|
|
||||||
### 6.2 Resolving Comments (Author Responsibility)
|
|
||||||
- [ ] **Reply Before Resolving**: When a review comment has been addressed, the author **must** reply to the specific thread explaining *how* the issue was resolved (e.g., commit SHA, approach taken, or a reasoned rejection with justification). Do not close a thread without a reply.
|
|
||||||
- [ ] **Link to Fix**: If the resolution is a code change, include the commit SHA or a reference to the changed line in the reply (e.g., `Fixed in abc1234 — moved the guard before CTS allocation`).
|
|
||||||
- [ ] **Close Only After Reply**: Mark a thread as **Resolved** only after posting the reply. A thread with no reply must remain open, even if the underlying code has changed.
|
|
||||||
- [ ] **Rejection Must Be Justified**: If the author disagrees with a comment and chooses not to act on it, they must reply with a clear technical justification. The reviewer then decides whether to accept the reasoning and close the thread, or escalate it.
|
|
||||||
|
|||||||
@@ -12,22 +12,14 @@ description: Design System & Component rules for Blazor
|
|||||||
|
|
||||||
- **Styling & Isolation:**
|
- **Styling & Isolation:**
|
||||||
- Mandatory use of scoped CSS (`.razor.css`).
|
- Mandatory use of scoped CSS (`.razor.css`).
|
||||||
- Strict compliance: Zero inline `<style>` tags are allowed in `.razor` files.
|
|
||||||
- No global CSS except for design tokens in `app.css`.
|
- No global CSS except for design tokens in `app.css`.
|
||||||
- Use `::deep` only when absolutely necessary to style child components.
|
- Use `::deep` only when absolutely necessary to style child components.
|
||||||
|
|
||||||
- **Design System (Nexus Neon):**
|
- **Design System (Nexus Neon):**
|
||||||
- **Color Palette:**
|
- **Color Palette:**
|
||||||
- Primary Accent: `--nexus-neon` (`#00ff99`) - Used for borders, highlights, and icons.
|
- Primary Accent: `--nexus-neon` (`#00ff99`) - Used for borders, highlights, and icons.
|
||||||
- Neon Glow: `--nexus-neon-glow` / `--nexus-primary-glow` (`rgba(0, 255, 153, 0.3)`).
|
|
||||||
- Dark Mode: `--nexus-bg` (`#0a0a0a`), `--nexus-card` (`#141414`).
|
- Dark Mode: `--nexus-bg` (`#0a0a0a`), `--nexus-card` (`#141414`).
|
||||||
- Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`).
|
- Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`).
|
||||||
- **No Brand Splitting:** Strict ban on custom purple/indigo/cyan elements or hardcoded accent colors (like `#7c3aed`, `#4c1d95`, `#00b3ff`) on dashboard pages. Neutral/glass surfaces (`rgba(255, 255, 255, 0.05)`) must be used for secondary elements to preserve contrast and ensure the AI/neon-green elements are the focal point.
|
|
||||||
- **Buttons:**
|
|
||||||
- Must inherit from the global `.btn-nexus` class in `app.css`.
|
|
||||||
- Primary Button: `.btn-nexus-primary` (background: `var(--nexus-neon)`, text color: `#000`).
|
|
||||||
- Secondary Button: `.btn-nexus-secondary` (background: `rgba(255, 255, 255, 0.05)`, border: `1px solid rgba(255, 255, 255, 0.1)`, text color: `#fff`).
|
|
||||||
- Hover Interaction: `transform: translateY(-2px)`, increased brightness, and a signature primary neon glow shadow.
|
|
||||||
- **Typography:**
|
- **Typography:**
|
||||||
- UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels.
|
- UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels.
|
||||||
- Reading Content: `Merriweather` (Serif) with `line-height: 1.65` and `letter-spacing: -0.01em` for high readability.
|
- Reading Content: `Merriweather` (Serif) with `line-height: 1.65` and `letter-spacing: -0.01em` for high readability.
|
||||||
@@ -52,9 +44,6 @@ description: Design System & Component rules for Blazor
|
|||||||
- **Interactive Flow:**
|
- **Interactive Flow:**
|
||||||
- AI Assistant interactions must be non-blocking and smoothly transition using CSS animations.
|
- AI Assistant interactions must be non-blocking and smoothly transition using CSS animations.
|
||||||
- Interactive elements must have clear `:hover`, `:active`, and `:focus` states.
|
- Interactive elements must have clear `:hover`, `:active`, and `:focus` states.
|
||||||
- **Glass Panel Standard:** All primary data panels (`.glass-panel`) must implement the following global parameters from `app.css` (only local modifiers like padding and hover offsets should live in scoped CSS):
|
- **Glass Panel Standard:** All primary data panels (`.glass-panel`) must implement the following interaction signature:
|
||||||
- Background: `rgba(20, 20, 20, 0.85)` (fallback) / `rgba(255, 255, 255, 0.03)` with `backdrop-filter: blur(10px)` when supported.
|
- `transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)`
|
||||||
- Border: `1px solid rgba(255, 255, 255, 0.05)`.
|
- `:hover` state must include: `transform: translateY(-4px)`, increased background opacity, and a subtle `--nexus-neon` border highlight (e.g., `rgba(0, 255, 153, 0.2)`).
|
||||||
- Border Radius: `var(--radius-xl)`.
|
|
||||||
- Transition: `all 0.3s cubic-bezier(0.4, 0, 0.2, 1)`.
|
|
||||||
- `:hover` state: `transform: translateY(-4px)` (or local offset) and highlight accent `border-color: rgba(0, 255, 153, 0.2)`.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# ===================================================================
|
|
||||||
# NexusReader — Staging (Stage) Environment Variables
|
|
||||||
# ===================================================================
|
|
||||||
# Copy this file to `.env.stage` and fill in the values before deployment:
|
|
||||||
# cp .env.stage.template .env.stage
|
|
||||||
#
|
|
||||||
# Then deploy with:
|
|
||||||
# docker compose -f docker-compose.stage.yml --env-file .env.stage up -d --build
|
|
||||||
# ===================================================================
|
|
||||||
|
|
||||||
# === PostgreSQL ===
|
|
||||||
POSTGRES_USER=nexus_user_stage
|
|
||||||
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
|
||||||
POSTGRES_DB=nexus_stage_db
|
|
||||||
POSTGRES_PORT=5438
|
|
||||||
|
|
||||||
# === Neo4j ===
|
|
||||||
NEO4J_USERNAME=neo4j
|
|
||||||
NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
|
||||||
|
|
||||||
# === Qdrant (leave empty to disable API key auth in staging) ===
|
|
||||||
QDRANT_API_KEY=
|
|
||||||
|
|
||||||
# === Web App ===
|
|
||||||
WEB_PORT=5080
|
|
||||||
|
|
||||||
# === Google OAuth (Staging credentials) ===
|
|
||||||
GOOGLE_CLIENT_ID=placeholder_google_client_id_stage
|
|
||||||
GOOGLE_CLIENT_SECRET=placeholder_google_client_secret_stage
|
|
||||||
|
|
||||||
# === Gemini AI ===
|
|
||||||
GOOGLE_AI_API_KEY=placeholder_gemini_api_key_stage
|
|
||||||
|
|
||||||
# === Secure Admin Seed Password (MANDATORY in Staging environment) ===
|
|
||||||
# This password is used by DbInitializer during startup. Cannot be empty or 'Admin123!'.
|
|
||||||
NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD
|
|
||||||
|
|
||||||
# === Non-standard ports for auxiliary services ===
|
|
||||||
QDRANT_HTTP_PORT=6383
|
|
||||||
QDRANT_GRPC_PORT=6384
|
|
||||||
NEO4J_HTTP_PORT=7488
|
|
||||||
NEO4J_BOLT_PORT=7688
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# ===================================================================
|
|
||||||
# NexusReader — Test Environment Variables
|
|
||||||
# ===================================================================
|
|
||||||
# Copy this file to `.env` and fill in the values before deployment:
|
|
||||||
# cp .env.test.template .env
|
|
||||||
#
|
|
||||||
# Then deploy with:
|
|
||||||
# docker compose -f docker-compose.test.yml up -d --build
|
|
||||||
# ===================================================================
|
|
||||||
|
|
||||||
# === PostgreSQL ===
|
|
||||||
POSTGRES_USER=nexus_user
|
|
||||||
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
|
||||||
POSTGRES_DB=nexus_test_db
|
|
||||||
POSTGRES_PORT=5433
|
|
||||||
|
|
||||||
# === Neo4j ===
|
|
||||||
NEO4J_USERNAME=neo4j
|
|
||||||
NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
|
||||||
|
|
||||||
# === Qdrant (leave empty to disable API key auth) ===
|
|
||||||
QDRANT_API_KEY=
|
|
||||||
|
|
||||||
# === Web App ===
|
|
||||||
WEB_PORT=5050
|
|
||||||
|
|
||||||
# === Google OAuth (placeholder for test) ===
|
|
||||||
GOOGLE_CLIENT_ID=placeholder
|
|
||||||
GOOGLE_CLIENT_SECRET=placeholder
|
|
||||||
|
|
||||||
# === Gemini AI (placeholder for test) ===
|
|
||||||
GOOGLE_AI_API_KEY=placeholder
|
|
||||||
|
|
||||||
# === Admin Seed Password ===
|
|
||||||
NEXUS_ADMIN_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
# === Non-standard ports for auxiliary services ===
|
|
||||||
QDRANT_HTTP_PORT=6343
|
|
||||||
QDRANT_GRPC_PORT=6344
|
|
||||||
NEO4J_HTTP_PORT=7484
|
|
||||||
NEO4J_BOLT_PORT=7697
|
|
||||||
@@ -29,8 +29,6 @@ Thumbs.db
|
|||||||
*.epub
|
*.epub
|
||||||
|
|
||||||
.fake
|
.fake
|
||||||
.env
|
|
||||||
.env.stage
|
|
||||||
src/NexusReader.Web/nexus.db
|
src/NexusReader.Web/nexus.db
|
||||||
src/NexusReader.Web/wwwroot/covers/
|
src/NexusReader.Web/wwwroot/covers/
|
||||||
src/NexusReader.Web/wwwroot/uploads/
|
src/NexusReader.Web/wwwroot/uploads/
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
|
|
||||||
<PackageVersion Include="Markdig" Version="0.38.0" />
|
|
||||||
<PackageVersion Include="Mapster" Version="10.0.7" />
|
<PackageVersion Include="Mapster" Version="10.0.7" />
|
||||||
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
|
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
|
||||||
<PackageVersion Include="MediatR" Version="12.1.1" />
|
<PackageVersion Include="MediatR" Version="12.1.1" />
|
||||||
|
|||||||
@@ -2,17 +2,12 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy props files and solution-level configurations for Central Package Management
|
|
||||||
COPY ["Directory.Build.props", "./"]
|
|
||||||
COPY ["Directory.Packages.props", "./"]
|
|
||||||
|
|
||||||
# Copy csproj files and restore dependencies
|
# Copy csproj files and restore dependencies
|
||||||
COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"]
|
COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"]
|
||||||
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
|
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
|
||||||
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
|
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
|
||||||
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
|
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
|
||||||
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
|
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
|
||||||
COPY ["src/NexusReader.Data/NexusReader.Data.csproj", "src/NexusReader.Data/"]
|
|
||||||
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
|
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
|
||||||
|
|
||||||
RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
|
RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
|
||||||
@@ -26,7 +21,6 @@ RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseApp
|
|||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/*
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,4 @@ version: 1.0
|
|||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **Git Workflow & Integration**
|
> **Git Workflow & Integration**
|
||||||
> All tasks originating from the repository must be performed on a separate branch. Every new chat must be launched from the `develop` branch. To connect to the Git repository, use the `gitea` MCP server.
|
> All tasks originating from the repository must be performed on a separate branch. To connect to the Git repository, use the `gitea` MCP server.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> **Docker Lifecycle Management**
|
|
||||||
> Before starting work, only the web (nexus) container needs to be stopped to prevent port/application conflicts (e.g., `./run-stage.sh --stop --nexus-only` or `-s -n`); database containers (PostgreSQL, Neo4j, Qdrant) should continue to run to support local development/debugging. After finishing work, a new version of the web container from the current branch should be rebuilt and restarted via `./run-stage.sh --nexus-only` (or `-n`).
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,13 +36,3 @@ Run test suite:
|
|||||||
```bash
|
```bash
|
||||||
dotnet test --no-restore
|
dotnet test --no-restore
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🗄️ Database Migrations
|
|
||||||
|
|
||||||
Automatic database migrations at startup (`MigrateAsync()`) have been disabled to ensure compatibility with Native AOT compilation and prevent locking issues in multi-instance environments.
|
|
||||||
|
|
||||||
To apply database migrations locally, run the EF Core migration command from the solution root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet ef database update --project src/NexusReader.Infrastructure --startup-project src/NexusReader.Web
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
services:
|
|
||||||
db:
|
|
||||||
image: pgvector/pgvector:pg17
|
|
||||||
container_name: nexus-db-stage
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-nexus_user_stage}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-nexus_stage_db}
|
|
||||||
ports:
|
|
||||||
- "${POSTGRES_PORT:-5438}:5432"
|
|
||||||
volumes:
|
|
||||||
- pgdata_stage:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-nexus_user_stage} -d $${POSTGRES_DB:-nexus_stage_db}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- nexus-stage
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
web:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: nexus-web-stage
|
|
||||||
ports:
|
|
||||||
- "${WEB_PORT:-5080}:5000"
|
|
||||||
environment:
|
|
||||||
- ASPNETCORE_ENVIRONMENT=Staging
|
|
||||||
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
|
||||||
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
|
||||||
- Qdrant__ApiKey=${QDRANT_API_KEY:-}
|
|
||||||
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
|
||||||
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
|
||||||
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
|
||||||
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder_google_client_id_stage}
|
|
||||||
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder_google_client_secret_stage}
|
|
||||||
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder_gemini_api_key_stage}
|
|
||||||
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:?NEXUS_ADMIN_PASSWORD is required}
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
qdrant:
|
|
||||||
condition: service_healthy
|
|
||||||
neo4j:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- stage_www_uploads:/app/wwwroot/uploads
|
|
||||||
- stage_www_covers:/app/wwwroot/covers
|
|
||||||
networks:
|
|
||||||
- nexus-stage
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
qdrant:
|
|
||||||
image: qdrant/qdrant:latest
|
|
||||||
container_name: nexus-qdrant-stage
|
|
||||||
environment:
|
|
||||||
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-}
|
|
||||||
ports:
|
|
||||||
- "${QDRANT_HTTP_PORT:-6383}:6333"
|
|
||||||
- "${QDRANT_GRPC_PORT:-6384}:6334"
|
|
||||||
volumes:
|
|
||||||
- qdrant_stage_data:/qdrant/storage
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- nexus-stage
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
neo4j:
|
|
||||||
image: neo4j:5-community
|
|
||||||
container_name: nexus-neo4j-stage
|
|
||||||
environment:
|
|
||||||
- NEO4J_AUTH=${NEO4J_USERNAME:-neo4j}/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
|
||||||
ports:
|
|
||||||
- "${NEO4J_HTTP_PORT:-7488}:7474"
|
|
||||||
- "${NEO4J_BOLT_PORT:-7688}:7687"
|
|
||||||
volumes:
|
|
||||||
- neo4j_stage_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 10
|
|
||||||
start_period: 30s
|
|
||||||
networks:
|
|
||||||
- nexus-stage
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata_stage:
|
|
||||||
qdrant_stage_data:
|
|
||||||
neo4j_stage_data:
|
|
||||||
stage_www_uploads:
|
|
||||||
stage_www_covers:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
nexus-stage:
|
|
||||||
driver: bridge
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
services:
|
|
||||||
db:
|
|
||||||
image: pgvector/pgvector:pg17
|
|
||||||
container_name: nexus-db-test
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-nexus_user}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-nexus_test_db}
|
|
||||||
ports:
|
|
||||||
- "${POSTGRES_PORT:-5433}:5432"
|
|
||||||
volumes:
|
|
||||||
- pgdata_test:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nexus_user} -d ${POSTGRES_DB:-nexus_test_db}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- nexus-test
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
web:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: nexus-web-test
|
|
||||||
ports:
|
|
||||||
- "${WEB_PORT:-5050}:5000"
|
|
||||||
environment:
|
|
||||||
- ASPNETCORE_ENVIRONMENT=Test
|
|
||||||
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_test_db};Username=${POSTGRES_USER:-nexus_user};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
|
||||||
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
|
||||||
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
|
||||||
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
|
||||||
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
|
||||||
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
|
|
||||||
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
|
|
||||||
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
|
|
||||||
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:?NEXUS_ADMIN_PASSWORD is required}
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
qdrant:
|
|
||||||
condition: service_healthy
|
|
||||||
neo4j:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- nexus-test
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
qdrant:
|
|
||||||
image: qdrant/qdrant:latest
|
|
||||||
container_name: nexus-qdrant-test
|
|
||||||
environment:
|
|
||||||
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-}
|
|
||||||
ports:
|
|
||||||
- "${QDRANT_HTTP_PORT:-6343}:6333"
|
|
||||||
- "${QDRANT_GRPC_PORT:-6344}:6334"
|
|
||||||
volumes:
|
|
||||||
- qdrant_test_data:/qdrant/storage
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- nexus-test
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
neo4j:
|
|
||||||
image: neo4j:5-community
|
|
||||||
container_name: nexus-neo4j-test
|
|
||||||
environment:
|
|
||||||
- NEO4J_AUTH=${NEO4J_USERNAME:-neo4j}/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
|
||||||
ports:
|
|
||||||
- "${NEO4J_HTTP_PORT:-7484}:7474"
|
|
||||||
- "${NEO4J_BOLT_PORT:-7697}:7687"
|
|
||||||
volumes:
|
|
||||||
- neo4j_test_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 10
|
|
||||||
start_period: 30s
|
|
||||||
networks:
|
|
||||||
- nexus-test
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata_test:
|
|
||||||
qdrant_test_data:
|
|
||||||
neo4j_test_data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
nexus-test:
|
|
||||||
driver: bridge
|
|
||||||
-154
@@ -1,154 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# -------------------------------------------------------------
|
|
||||||
# Staging Deploy & Orchestration Helper for NexusReader
|
|
||||||
# -------------------------------------------------------------
|
|
||||||
set -e
|
|
||||||
|
|
||||||
NEXUS_ONLY=false
|
|
||||||
STOP=false
|
|
||||||
for arg in "$@"; do
|
|
||||||
case $arg in
|
|
||||||
--nexus-only|-n)
|
|
||||||
NEXUS_ONLY=true
|
|
||||||
;;
|
|
||||||
--stop|-s)
|
|
||||||
STOP=true
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
ENV_FILE=".env.stage"
|
|
||||||
TEMPLATE_FILE=".env.stage.template"
|
|
||||||
COMPOSE_FILE="docker-compose.stage.yml"
|
|
||||||
|
|
||||||
if [ "$STOP" = true ]; then
|
|
||||||
echo "🛑 Stopping staging environment..."
|
|
||||||
if [ ! -f "$ENV_FILE" ] && [ -f "$TEMPLATE_FILE" ]; then
|
|
||||||
cp "$TEMPLATE_FILE" "$ENV_FILE"
|
|
||||||
fi
|
|
||||||
if [ "$NEXUS_ONLY" = true ]; then
|
|
||||||
echo "🧹 Stopping and removing only the web (nexus) container..."
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
|
|
||||||
else
|
|
||||||
echo "🧹 Stopping all containers..."
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
|
|
||||||
docker compose down --remove-orphans 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
echo "✅ Staging environment stopped."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🏁 Starting staging environment orchestration..."
|
|
||||||
if [ "$NEXUS_ONLY" = true ]; then
|
|
||||||
echo "ℹ️ Mode: --nexus-only (only the web/nexus application container will be modified)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 1. Create .env.stage if it doesn't exist
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
|
||||||
if [ -f "$TEMPLATE_FILE" ]; then
|
|
||||||
echo "📄 Creating $ENV_FILE from $TEMPLATE_FILE..."
|
|
||||||
cp "$TEMPLATE_FILE" "$ENV_FILE"
|
|
||||||
else
|
|
||||||
echo "❌ Error: Template file $TEMPLATE_FILE not found."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Check and generate secure random passwords for placeholders
|
|
||||||
if grep -q "CHANGE_ME_TO_STRONG_PASSWORD" "$ENV_FILE"; then
|
|
||||||
echo "🔐 Generating secure random passwords in $ENV_FILE..."
|
|
||||||
PG_PASS=$(openssl rand -hex 16)
|
|
||||||
NEO_PASS=$(openssl rand -hex 16)
|
|
||||||
# Use standard sed compatible with Linux
|
|
||||||
sed -i "s/POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD/POSTGRES_PASSWORD=$PG_PASS/g" "$ENV_FILE"
|
|
||||||
sed -i "s/NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD/NEO4J_PASSWORD=$NEO_PASS/g" "$ENV_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -q "CHANGE_ME_TO_SECURE_ADMIN_PASSWORD" "$ENV_FILE"; then
|
|
||||||
echo "🔐 Generating secure admin seed password in $ENV_FILE..."
|
|
||||||
ADMIN_PASS=$(openssl rand -hex 16)
|
|
||||||
sed -i "s/NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD/NEXUS_ADMIN_PASSWORD=$ADMIN_PASS/g" "$ENV_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -q "^QDRANT_API_KEY=$" "$ENV_FILE" || grep -q "^QDRANT_API_KEY=[[:space:]]*$" "$ENV_FILE"; then
|
|
||||||
echo "🔐 Generating secure random Qdrant API key in $ENV_FILE..."
|
|
||||||
QD_KEY=$(openssl rand -hex 16)
|
|
||||||
sed -i "s/^QDRANT_API_KEY=.*/QDRANT_API_KEY=$QD_KEY/g" "$ENV_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Load staging variables for local execution context (needed for ports/migrations)
|
|
||||||
# Clean up carriage returns just in case
|
|
||||||
POSTGRES_USER=$(grep "^POSTGRES_USER=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
|
||||||
POSTGRES_PASSWORD=$(grep "^POSTGRES_PASSWORD=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
|
||||||
POSTGRES_DB=$(grep "^POSTGRES_DB=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
|
||||||
POSTGRES_PORT=$(grep "^POSTGRES_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
|
||||||
WEB_PORT=$(grep "^WEB_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
|
||||||
QDRANT_HTTP_PORT=$(grep "^QDRANT_HTTP_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
|
||||||
NEO4J_HTTP_PORT=$(grep "^NEO4J_HTTP_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
|
|
||||||
|
|
||||||
# Fallbacks in case env parsing is empty
|
|
||||||
POSTGRES_PORT=${POSTGRES_PORT:-5438}
|
|
||||||
WEB_PORT=${WEB_PORT:-5080}
|
|
||||||
|
|
||||||
# 3. Stop any conflicting Docker Compose environments
|
|
||||||
if [ "$NEXUS_ONLY" = true ]; then
|
|
||||||
echo "🧹 Stopping and removing only the web (nexus) container..."
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
|
|
||||||
else
|
|
||||||
echo "🧹 Stopping existing containers..."
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
|
|
||||||
docker compose down --remove-orphans 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4. Build and start containers
|
|
||||||
if [ "$NEXUS_ONLY" = true ]; then
|
|
||||||
echo "🚀 Building and restarting only the web (nexus) container..."
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build web
|
|
||||||
else
|
|
||||||
echo "🚀 Building and starting staging containers..."
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 5. Wait for Database to be healthy
|
|
||||||
echo "⏳ Waiting for database (nexus-db-stage) to become healthy..."
|
|
||||||
MAX_ATTEMPTS=30
|
|
||||||
attempt=0
|
|
||||||
until [ "$(docker inspect --format='{{json .State.Health.Status}}' nexus-db-stage 2>/dev/null)" == "\"healthy\"" ]; do
|
|
||||||
sleep 2
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
if [ $attempt -ge $MAX_ATTEMPTS ]; then
|
|
||||||
echo "❌ Timeout: Database container never became healthy."
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs db
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "✅ Database is healthy!"
|
|
||||||
|
|
||||||
# 6. Apply Entity Framework migrations
|
|
||||||
echo "🔄 Applying EF Core migrations to staging database on port $POSTGRES_PORT..."
|
|
||||||
export ConnectionStrings__PostgresConnection="Host=127.0.0.1;Port=$POSTGRES_PORT;Database=$POSTGRES_DB;Username=$POSTGRES_USER;Password=$POSTGRES_PASSWORD"
|
|
||||||
dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web --no-build
|
|
||||||
|
|
||||||
# 7. Wait for Web Application to respond
|
|
||||||
echo "⏳ Waiting for Web Application to start on http://localhost:$WEB_PORT/health..."
|
|
||||||
MAX_WEB_ATTEMPTS=30
|
|
||||||
web_attempt=0
|
|
||||||
until curl -s -f "http://localhost:$WEB_PORT/health" >/dev/null; do
|
|
||||||
sleep 2
|
|
||||||
web_attempt=$((web_attempt + 1))
|
|
||||||
if [ $web_attempt -ge $MAX_WEB_ATTEMPTS ]; then
|
|
||||||
echo "⚠️ Warning: Web app is not responding yet on http://localhost:$WEB_PORT/health, but let's check logs..."
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs web
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "🎉 Staging environment is ready!"
|
|
||||||
echo "--------------------------------------------------------"
|
|
||||||
echo "🌐 Web Application: http://localhost:$WEB_PORT"
|
|
||||||
echo "🗄️ PostgreSQL Port: $POSTGRES_PORT"
|
|
||||||
echo "🔎 Neo4j Console: http://localhost:$NEO4J_HTTP_PORT"
|
|
||||||
echo "📊 Qdrant Service: http://localhost:$QDRANT_HTTP_PORT"
|
|
||||||
echo "--------------------------------------------------------"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Read-only abstraction for fetching concepts map data.
|
|
||||||
/// Defined in the Application layer to avoid a direct dependency on EF Core / NexusReader.Data.
|
|
||||||
/// </summary>
|
|
||||||
public interface IConceptsMapReadRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the last read page ID for the specified user.
|
|
||||||
/// </summary>
|
|
||||||
Task<string?> GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all knowledge units associated with a book, scoped by tenant.
|
|
||||||
/// </summary>
|
|
||||||
Task<List<KnowledgeUnit>> GetKnowledgeUnitsForBookAsync(
|
|
||||||
Guid bookId,
|
|
||||||
string tenantId,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -23,11 +23,6 @@ public interface IEbookRepository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
void AddEbook(Ebook ebook);
|
void AddEbook(Ebook ebook);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Finds an ebook by its unique identifier.
|
|
||||||
/// </summary>
|
|
||||||
Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists all staged changes to the underlying store.
|
/// Persists all staged changes to the underlying store.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
using NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Abstraction for QuizResult and related User entity lookup.
|
|
||||||
/// Defined in the Application layer to maintain Clean Architecture isolation.
|
|
||||||
/// </summary>
|
|
||||||
public interface IQuizResultRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Finds a user by ID to extract tenant context.
|
|
||||||
/// </summary>
|
|
||||||
Task<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a new quiz result to the database.
|
|
||||||
/// </summary>
|
|
||||||
void AddQuizResult(QuizResult quizResult);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Persists all staged changes to the repository.
|
|
||||||
/// </summary>
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides access to user library ownership details, decoupling the relational database
|
|
||||||
/// structures from vector search and intelligence query operations.
|
|
||||||
/// </summary>
|
|
||||||
public interface IUserLibraryStore
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves a list of book IDs that are owned by or uploaded for the specified user.
|
|
||||||
/// </summary>
|
|
||||||
Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves a dictionary mapping book IDs to their titles.
|
|
||||||
/// </summary>
|
|
||||||
Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decoupled database store to retrieve active user reading states and chapter content.
|
|
||||||
/// </summary>
|
|
||||||
public interface IUserReadingStateStore
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the user's active reading state: last read ebook ID, last opened chapter/page ID, and tenant ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the text content of a specific chapter/page by its ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a chunk of text retrieved from the semantic vector database.
|
|
||||||
/// </summary>
|
|
||||||
public record VectorChunk(string Content, string EbookId, double Score, string MetadataJson = "", string BookTitle = "", string ChapterTitle = "");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Abstraction for performing semantic vector searches, isolating Qdrant gRPC dependencies from the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IVectorSearchStore
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches.
|
|
||||||
/// </summary>
|
|
||||||
Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Searches within a whitelist of owned book IDs for the best semantic matches.
|
|
||||||
/// </summary>
|
|
||||||
Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches, excluding a specific book ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using NexusReader.Application.Queries.Concepts;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
public interface IConceptsMapService
|
|
||||||
{
|
|
||||||
Task<Result<BookConceptsMapResultDto>> GetConceptsMapAsync(Guid bookId);
|
|
||||||
}
|
|
||||||
@@ -20,17 +20,4 @@ public interface IEpubReader
|
|||||||
int chapterIndex,
|
int chapterIndex,
|
||||||
string? userId = null,
|
string? userId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves a resource (like an image) from the EPUB as a byte array.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ebookId">The unique ID of the ebook to read.</param>
|
|
||||||
/// <param name="resourcePath">The path of the resource within the EPUB archive.</param>
|
|
||||||
/// <param name="userId">The authenticated user's ID (used for tenant isolation).</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
Task<Result<byte[]>> GetEpubResourceAsync(
|
|
||||||
Guid ebookId,
|
|
||||||
string resourcePath,
|
|
||||||
string? userId = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
using NexusReader.Application.Queries.Intelligence;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Services;
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ public interface IKnowledgeService
|
|||||||
Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
|
Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
|
||||||
Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
||||||
Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default);
|
Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default);
|
||||||
Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default);
|
|
||||||
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service for sanitizing raw input text (e.g. Markdown/HTML) to protect against XSS injection.
|
|
||||||
/// Intended to have a Singleton lifetime.
|
|
||||||
/// </summary>
|
|
||||||
public interface ISanitizerService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Sanitizes the input string and returns a clean, safe version.
|
|
||||||
/// </summary>
|
|
||||||
string Sanitize(string input);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// General file storage service interface for handling media uploads.
|
|
||||||
/// Intended to have a Scoped lifetime.
|
|
||||||
/// </summary>
|
|
||||||
public interface IStorageService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Uploads a file stream and returns its public URL/path.
|
|
||||||
/// </summary>
|
|
||||||
Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Uploads file bytes and returns its public URL/path.
|
|
||||||
/// </summary>
|
|
||||||
Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using NexusReader.Domain.Enums;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
public interface IUserPreferenceStore
|
|
||||||
{
|
|
||||||
Task<Result> SaveThemePreferenceAsync(ThemeMode mode);
|
|
||||||
Task<Result<ThemeMode>> GetThemePreferenceAsync();
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using System.Linq;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
@@ -80,38 +78,16 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
|||||||
|
|
||||||
// 4. Trigger asynchronous background processing and vector indexing
|
// 4. Trigger asynchronous background processing and vector indexing
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<IngestEbookCommandHandler>>();
|
|
||||||
var broadcaster = scope.ServiceProvider.GetRequiredService<ISyncBroadcaster>();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||||
var result = await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId));
|
await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId));
|
||||||
if (result.IsFailed)
|
|
||||||
{
|
|
||||||
var errorMsg = string.Join("; ", result.Errors.Select(e => e.Message));
|
|
||||||
logger.LogError("[IngestEbook] Background ebook processing failed for Ebook {EbookId}: {Error}", ebook.Id, errorMsg);
|
|
||||||
await broadcaster.BroadcastIngestionProgressAsync(
|
|
||||||
request.UserId,
|
|
||||||
$"Błąd indeksowania: {errorMsg}",
|
|
||||||
1.0);
|
|
||||||
}
|
}
|
||||||
}
|
catch (Exception)
|
||||||
catch (Exception ex)
|
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "[IngestEbook] Exception during background ebook processing for Ebook {EbookId}", ebook.Id);
|
// Swallowed to prevent ThreadPool crashes
|
||||||
try
|
|
||||||
{
|
|
||||||
await broadcaster.BroadcastIngestionProgressAsync(
|
|
||||||
request.UserId,
|
|
||||||
$"Błąd krytyczny podczas przetwarzania e-booka: {ex.Message}",
|
|
||||||
1.0);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Ignore broadcast failures to prevent crashes
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
|
||||||
namespace NexusReader.Application.Commands.Library;
|
namespace NexusReader.Application.Commands.Library;
|
||||||
|
|
||||||
@@ -18,20 +18,20 @@ public record ProcessEbookCommand(
|
|||||||
|
|
||||||
public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, Result<bool>>
|
public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, Result<bool>>
|
||||||
{
|
{
|
||||||
private readonly IEbookRepository _ebookRepository;
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
private readonly IKnowledgeService _knowledgeService;
|
private readonly IKnowledgeService _knowledgeService;
|
||||||
private readonly IEpubExtractor _epubExtractor;
|
private readonly IEpubExtractor _epubExtractor;
|
||||||
private readonly ISyncBroadcaster _broadcaster;
|
private readonly ISyncBroadcaster _broadcaster;
|
||||||
private readonly ILogger<ProcessEbookCommandHandler> _logger;
|
private readonly ILogger<ProcessEbookCommandHandler> _logger;
|
||||||
|
|
||||||
public ProcessEbookCommandHandler(
|
public ProcessEbookCommandHandler(
|
||||||
IEbookRepository ebookRepository,
|
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||||
IKnowledgeService knowledgeService,
|
IKnowledgeService knowledgeService,
|
||||||
IEpubExtractor epubExtractor,
|
IEpubExtractor epubExtractor,
|
||||||
ISyncBroadcaster broadcaster,
|
ISyncBroadcaster broadcaster,
|
||||||
ILogger<ProcessEbookCommandHandler> logger)
|
ILogger<ProcessEbookCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_ebookRepository = ebookRepository;
|
_dbContextFactory = dbContextFactory;
|
||||||
_knowledgeService = knowledgeService;
|
_knowledgeService = knowledgeService;
|
||||||
_epubExtractor = epubExtractor;
|
_epubExtractor = epubExtractor;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
@@ -46,7 +46,8 @@ public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, R
|
|||||||
{
|
{
|
||||||
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken);
|
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken);
|
||||||
|
|
||||||
var ebook = await _ebookRepository.FindByIdAsync(request.EbookId, cancellationToken);
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var ebook = await dbContext.Ebooks.FindAsync(new object[] { request.EbookId }, cancellationToken);
|
||||||
if (ebook == null)
|
if (ebook == null)
|
||||||
{
|
{
|
||||||
_logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId);
|
_logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId);
|
||||||
@@ -121,7 +122,7 @@ public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, R
|
|||||||
|
|
||||||
// Mark the ebook as ready
|
// Mark the ebook as ready
|
||||||
ebook.IsReadyForReading = true;
|
ebook.IsReadyForReading = true;
|
||||||
await _ebookRepository.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
_logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title);
|
_logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title);
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
using NexusReader.Data.Persistence;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
|
|
||||||
namespace NexusReader.Application.Commands.Quiz;
|
namespace NexusReader.Application.Commands.Quiz;
|
||||||
|
|
||||||
public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizResultCommand>
|
public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizResultCommand>
|
||||||
{
|
{
|
||||||
private readonly IQuizResultRepository _quizResultRepository;
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
|
|
||||||
public SubmitQuizResultCommandHandler(IQuizResultRepository quizResultRepository)
|
public SubmitQuizResultCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||||
{
|
{
|
||||||
_quizResultRepository = quizResultRepository;
|
_dbContextFactory = dbContextFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> Handle(SubmitQuizResultCommand request, CancellationToken cancellationToken)
|
public async Task<Result> Handle(SubmitQuizResultCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var user = await _quizResultRepository.FindUserByIdAsync(request.UserId, cancellationToken);
|
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
return Result.Fail("User not found.");
|
return Result.Fail("User not found.");
|
||||||
@@ -33,8 +36,8 @@ public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizR
|
|||||||
CompletedDate = DateTime.UtcNow
|
CompletedDate = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
_quizResultRepository.AddQuizResult(quizResult);
|
context.QuizResults.Add(quizResult);
|
||||||
await _quizResultRepository.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Domain.Enums;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Commands.User;
|
|
||||||
|
|
||||||
public record UpdateThemeCommand(string UserId, ThemeMode Mode) : ICommand;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Commands.User;
|
|
||||||
|
|
||||||
public class UpdateThemeCommandHandler : ICommandHandler<UpdateThemeCommand>
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public UpdateThemeCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result> Handle(UpdateThemeCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
var user = await dbContext.Users
|
|
||||||
.AsTracking()
|
|
||||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return Result.Fail("User not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
user.ThemePreference = request.Mode;
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Fail(new Error("Failed to save theme preference in database.").CausedBy(ex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Collections.Generic;
|
|
||||||
using NexusReader.Application.Queries.Graph;
|
using NexusReader.Application.Queries.Graph;
|
||||||
using NexusReader.Application.Queries.Intelligence;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Common;
|
namespace NexusReader.Application.Common;
|
||||||
|
|
||||||
@@ -11,28 +9,6 @@ namespace NexusReader.Application.Common;
|
|||||||
[JsonSerializable(typeof(GraphDataDto))]
|
[JsonSerializable(typeof(GraphDataDto))]
|
||||||
[JsonSerializable(typeof(List<GraphNodeDto>))]
|
[JsonSerializable(typeof(List<GraphNodeDto>))]
|
||||||
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
||||||
[JsonSerializable(typeof(GetGlobalIntelligenceRequest))]
|
|
||||||
[JsonSerializable(typeof(IntelligenceResponse))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))]
|
|
||||||
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.User.UpdateThemeRequest))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Domain.Enums.ThemeMode))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterRequest))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterResponse))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.UploadResultDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.LocalBackupEnvelope))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.AutosaveChapterRequest))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.CreateBookCommand))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookRequestDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookResponseDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorDashboardDataDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.DashboardMetricsDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto))]
|
|
||||||
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookDto>))]
|
|
||||||
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto>))]
|
|
||||||
public partial class AppJsonContext : JsonSerializerContext
|
public partial class AppJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
namespace NexusReader.Application.Common;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configurations for the monetization engine, controlling the thresholds at which
|
|
||||||
/// search queries trigger paywalls.
|
|
||||||
/// </summary>
|
|
||||||
public class RagMonetizationOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "RagMonetization";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The baseline score threshold above which global content might trigger a paywall if there is no local content.
|
|
||||||
/// Default: 0.45.
|
|
||||||
/// </summary>
|
|
||||||
public double BaselineThreshold { get; set; } = 0.45;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The similarity gap (Delta) required between global and local content to trigger an upgrade paywall.
|
|
||||||
/// Default: 0.15.
|
|
||||||
/// </summary>
|
|
||||||
public double DeltaThreshold { get; set; } = 0.15;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The absolute score required from global content to trigger an upgrade paywall.
|
|
||||||
/// Default: 0.70.
|
|
||||||
/// </summary>
|
|
||||||
public double UpgradeThreshold { get; set; } = 0.70;
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.DTOs.Creator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Telemetry metrics for the Creator Dashboard.
|
|
||||||
/// </summary>
|
|
||||||
public record DashboardMetricsDto(
|
|
||||||
int TotalReads,
|
|
||||||
double AvgReadTimeMinutes,
|
|
||||||
int ActiveReaders,
|
|
||||||
decimal GrossRevenue
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lightweight revision details for the Creator Dashboard.
|
|
||||||
/// </summary>
|
|
||||||
public record CreatorBookRevisionDto(
|
|
||||||
Guid Id,
|
|
||||||
string VersionString,
|
|
||||||
bool IsPublished,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime? PublishedAt
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lightweight book publication details for the Creator Dashboard.
|
|
||||||
/// </summary>
|
|
||||||
public record CreatorBookDto(
|
|
||||||
Guid Id,
|
|
||||||
string Title,
|
|
||||||
int WordCount,
|
|
||||||
int AggregatedReads,
|
|
||||||
Guid? FirstChapterId,
|
|
||||||
CreatorBookRevisionDto? LivePublishedRevision,
|
|
||||||
CreatorBookRevisionDto? CurrentDraftRevision
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Root data envelope for Creator Dashboard loading.
|
|
||||||
/// </summary>
|
|
||||||
public record CreatorDashboardDataDto(
|
|
||||||
DashboardMetricsDto Metrics,
|
|
||||||
List<CreatorBookDto> Books
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for creating a new Book.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateBookRequestDto(
|
|
||||||
string Title,
|
|
||||||
string? Description
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for creating a new Book.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateBookResponseDto(
|
|
||||||
Guid BookId
|
|
||||||
);
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace NexusReader.Application.DTOs.Media;
|
|
||||||
|
|
||||||
// Note: These DTOs are registered in AppJsonContext.cs for JSON source generation.
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for chapter validation/sanitization.
|
|
||||||
/// </summary>
|
|
||||||
public record ValidateChapterRequest(string Content);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO containing sanitized chapter content.
|
|
||||||
/// </summary>
|
|
||||||
public record ValidateChapterResponse(string SanitizedContent);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO containing the uploaded media file URL.
|
|
||||||
/// </summary>
|
|
||||||
public record UploadResultDto(string Url);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a structured JSON backup envelope stored in LocalStorage.
|
|
||||||
/// </summary>
|
|
||||||
public class LocalBackupEnvelope
|
|
||||||
{
|
|
||||||
public Guid ChapterId { get; set; }
|
|
||||||
public DateTime Timestamp { get; set; }
|
|
||||||
public string MarkdownContent { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for chapter autosaving.
|
|
||||||
/// </summary>
|
|
||||||
public record AutosaveChapterRequest(string MarkdownContent);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
using NexusReader.Domain.Enums;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.DTOs.User;
|
|
||||||
|
|
||||||
public record UpdateThemeRequest(ThemeMode Mode);
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using NexusReader.Application.Constants;
|
using NexusReader.Application.Constants;
|
||||||
using NexusReader.Domain.Enums;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.DTOs.User;
|
namespace NexusReader.Application.DTOs.User;
|
||||||
|
|
||||||
@@ -9,7 +8,6 @@ public record UserProfileDto
|
|||||||
public string UserId { get; init; } = string.Empty;
|
public string UserId { get; init; } = string.Empty;
|
||||||
public int AITokensUsed { get; init; }
|
public int AITokensUsed { get; init; }
|
||||||
public Guid TenantId { get; init; }
|
public Guid TenantId { get; init; }
|
||||||
public ThemeMode ThemePreference { get; init; } = ThemeMode.System;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Relational data for the current subscription plan.
|
/// Relational data for the current subscription plan.
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
using System;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Features.Books.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command to create a new Book, initialize its first Working Draft revision, and seed it with a default Introduction chapter.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Title">The title of the new book.</param>
|
|
||||||
/// <param name="Description">An optional description of the book.</param>
|
|
||||||
/// <param name="UserId">The ID of the creator user.</param>
|
|
||||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
|
||||||
public record CreateBookCommand(
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
string UserId,
|
|
||||||
string TenantId
|
|
||||||
) : ICommand<Guid>;
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Features.Books.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// MediatR handler for creating a Book, creating its initial Working Draft revision,
|
|
||||||
/// and seeding a default first chapter ("Introduction") in an atomic database transaction.
|
|
||||||
/// </summary>
|
|
||||||
public class CreateBookCommandHandler : ICommandHandler<CreateBookCommand, Guid>
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public CreateBookCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<Guid>> Handle(CreateBookCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Title))
|
|
||||||
{
|
|
||||||
return Result.Fail<Guid>(new Error("Book title is required."));
|
|
||||||
}
|
|
||||||
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 1. Instantiate the Book record mapping Title, UserId, and TenantId
|
|
||||||
var book = new Book
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Title = request.Title.Trim(),
|
|
||||||
UserId = request.UserId,
|
|
||||||
TenantId = request.TenantId,
|
|
||||||
CurrentDraftRevisionId = null,
|
|
||||||
LivePublishedRevisionId = null
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.Books.Add(book);
|
|
||||||
|
|
||||||
// 2. Instantiate the initial BookRevision designated as "Working Draft"
|
|
||||||
var draftRevision = new BookRevision
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookId = book.Id,
|
|
||||||
VersionString = "Working Draft",
|
|
||||||
IsPublished = false,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.BookRevisions.Add(draftRevision);
|
|
||||||
|
|
||||||
// 3. Automatically instantiate and append a default first Chapter to this new revision
|
|
||||||
var introChapter = new Chapter
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookRevisionId = draftRevision.Id,
|
|
||||||
Title = "Introduction",
|
|
||||||
MarkdownContent = "# Introduction\nStart writing here...",
|
|
||||||
SortOrder = 1
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.Chapters.Add(introChapter);
|
|
||||||
|
|
||||||
// Save first to generate DB references
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 4. Inject the newly instantiated draft revision ID back into Book.CurrentDraftRevisionId
|
|
||||||
book.CurrentDraftRevisionId = draftRevision.Id;
|
|
||||||
|
|
||||||
// Save the updated Book link
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Commit transaction
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok(book.Id);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception rollbackEx)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[CreateBook] Transaction rollback failed: {rollbackEx.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Fail<Guid>(new Error($"Failed to create book: {ex.Message}").CausedBy(ex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Features.Books.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command to publish a new frozen version of a Book, and create a new Working Draft.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="BookId">The unique identifier of the Book to publish.</param>
|
|
||||||
/// <param name="CustomVersionString">The custom version string to apply (e.g. "v1.0").</param>
|
|
||||||
/// <param name="UserId">The ID of the user requesting the action.</param>
|
|
||||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
|
||||||
public record PublishBookVersionCommand(Guid BookId, string CustomVersionString, string UserId, string TenantId) : ICommand;
|
|
||||||
-112
@@ -1,112 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Entities;
|
|
||||||
using NexusReader.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Features.Books.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// MediatR handler for publishing a Book version and setting up the next Working Draft.
|
|
||||||
/// </summary>
|
|
||||||
public class PublishBookVersionCommandHandler : ICommandHandler<PublishBookVersionCommand>
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public PublishBookVersionCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result> Handle(PublishBookVersionCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Fetch the Book including its CurrentDraftRevision and all associated Chapters,
|
|
||||||
// enforcing that the book belongs to the requested TenantId and UserId to prevent cross-tenant data leaks.
|
|
||||||
var book = await dbContext.Books
|
|
||||||
.Include(b => b.CurrentDraftRevision)
|
|
||||||
.ThenInclude(r => r!.Chapters)
|
|
||||||
.FirstOrDefaultAsync(
|
|
||||||
b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (book == null)
|
|
||||||
{
|
|
||||||
return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found."));
|
|
||||||
}
|
|
||||||
|
|
||||||
var oldDraftRevision = book.CurrentDraftRevision;
|
|
||||||
if (oldDraftRevision == null)
|
|
||||||
{
|
|
||||||
return Result.Fail(new Error("The book does not have an active draft revision to publish."));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start ACID transaction
|
|
||||||
using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 1. Update the current draft revision: Set IsPublished = true, PublishedAt = now, VersionString = custom
|
|
||||||
oldDraftRevision.IsPublished = true;
|
|
||||||
oldDraftRevision.PublishedAt = DateTime.UtcNow;
|
|
||||||
oldDraftRevision.VersionString = request.CustomVersionString;
|
|
||||||
|
|
||||||
// 2. Point the Book.LivePublishedRevisionId to this newly frozen revision ID
|
|
||||||
book.LivePublishedRevisionId = oldDraftRevision.Id;
|
|
||||||
|
|
||||||
// 3. Execute Deep Snapshot: Instantiate a brand new BookRevision representing the next "Working Draft"
|
|
||||||
var newDraftRevision = new BookRevision
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookId = book.Id,
|
|
||||||
VersionString = "Working Draft",
|
|
||||||
IsPublished = false,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.BookRevisions.Add(newDraftRevision);
|
|
||||||
|
|
||||||
// Replicate/clone chapters into new Chapter objects associated with the new draft revision.
|
|
||||||
// Reset identities by explicitly instantiating completely new Chapter objects with Guid.NewGuid().
|
|
||||||
foreach (var oldChapter in oldDraftRevision.Chapters)
|
|
||||||
{
|
|
||||||
var newChapter = new Chapter
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookRevisionId = newDraftRevision.Id,
|
|
||||||
Title = oldChapter.Title,
|
|
||||||
MarkdownContent = oldChapter.MarkdownContent,
|
|
||||||
SortOrder = oldChapter.SortOrder
|
|
||||||
};
|
|
||||||
dbContext.Chapters.Add(newChapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Assign the new draft revision ID to Book.CurrentDraftRevisionId
|
|
||||||
book.CurrentDraftRevisionId = newDraftRevision.Id;
|
|
||||||
|
|
||||||
// Save changes and commit transaction
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception rollbackEx)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[PublishBookVersion] Transaction rollback failed: {rollbackEx.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Fail(new Error($"Failed to publish book version: {ex.Message}").CausedBy(ex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using NexusReader.Application.Queries.Graph;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Concepts;
|
|
||||||
|
|
||||||
public record BookConceptsMapResultDto(
|
|
||||||
[property: JsonPropertyName("nodes")] List<GraphNodeDto> Nodes,
|
|
||||||
[property: JsonPropertyName("lastReadBlockId")] string LastReadBlockId
|
|
||||||
);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Concepts;
|
|
||||||
|
|
||||||
public record GetBookConceptsMapQuery(
|
|
||||||
Guid BookId,
|
|
||||||
string UserId,
|
|
||||||
string TenantId
|
|
||||||
) : IQuery<BookConceptsMapResultDto>;
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FluentResults;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
|
||||||
using NexusReader.Application.Queries.Graph;
|
|
||||||
using NexusReader.Application.Utilities;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Concepts;
|
|
||||||
|
|
||||||
internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler<GetBookConceptsMapQuery, BookConceptsMapResultDto>
|
|
||||||
{
|
|
||||||
private readonly IConceptsMapReadRepository _repository;
|
|
||||||
|
|
||||||
public GetBookConceptsMapQueryHandler(IConceptsMapReadRepository repository)
|
|
||||||
{
|
|
||||||
_repository = repository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<BookConceptsMapResultDto>> Handle(GetBookConceptsMapQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. Fetch user to extract reading progress (LastReadPageId)
|
|
||||||
var lastReadPageId = await _repository.GetLastReadPageIdAsync(request.UserId, cancellationToken);
|
|
||||||
var lastReadBlockId = lastReadPageId ?? string.Empty;
|
|
||||||
|
|
||||||
// 2. Fetch all KnowledgeUnits associated with the ebook and user's tenant
|
|
||||||
var units = await _repository.GetKnowledgeUnitsForBookAsync(request.BookId, request.TenantId, cancellationToken);
|
|
||||||
|
|
||||||
var nodes = new List<GraphNodeDto>();
|
|
||||||
|
|
||||||
foreach (var unit in units)
|
|
||||||
{
|
|
||||||
// Only process units representing sections or conceptual milestones (usually starting with "seg-")
|
|
||||||
if (string.IsNullOrEmpty(unit.Id) || !unit.Id.StartsWith("seg-"))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
string label = unit.Id;
|
|
||||||
string group = "concept";
|
|
||||||
string summary = unit.Content;
|
|
||||||
var keyTerms = new List<string>();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(unit.MetadataJson))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(unit.MetadataJson);
|
|
||||||
if (doc.RootElement.TryGetProperty("label", out var labelProp))
|
|
||||||
label = labelProp.GetString() ?? label;
|
|
||||||
if (doc.RootElement.TryGetProperty("group", out var groupProp))
|
|
||||||
group = groupProp.GetString() ?? group;
|
|
||||||
if (doc.RootElement.TryGetProperty("summary", out var summaryProp))
|
|
||||||
summary = summaryProp.GetString() ?? summary;
|
|
||||||
if (doc.RootElement.TryGetProperty("key_terms", out var ktProp) && ktProp.ValueKind == JsonValueKind.Array)
|
|
||||||
{
|
|
||||||
foreach (var term in ktProp.EnumerateArray())
|
|
||||||
{
|
|
||||||
if (term.GetString() is string s)
|
|
||||||
keyTerms.Add(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Fallback to defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nodes.Add(new GraphNodeDto(
|
|
||||||
Id: unit.Id,
|
|
||||||
Label: label,
|
|
||||||
Group: group,
|
|
||||||
Description: unit.Content,
|
|
||||||
Type: unit.Type.ToString(),
|
|
||||||
Summary: summary,
|
|
||||||
KeyTerms: keyTerms
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return sorted by the numeric value in the seg-ID to ensure topdown vertical alignment
|
|
||||||
var sortedNodes = nodes
|
|
||||||
.OrderBy(n => SegmentIdParser.Parse(n.Id))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Result.Ok(new BookConceptsMapResultDto(sortedNodes, lastReadBlockId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Application.DTOs.Creator;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Creator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Query to load all revisions for a specific Book, checking multi-tenant ownership boundaries.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="BookId">The unique identifier of the target Book.</param>
|
|
||||||
/// <param name="UserId">The ID of the creator requesting revision data.</param>
|
|
||||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
|
||||||
public record GetBookRevisionsQuery(Guid BookId, string UserId, string TenantId) : IQuery<List<CreatorBookRevisionDto>>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler that lists past revisions of a Book, verifying ownership to prevent cross-tenant leakages.
|
|
||||||
/// </summary>
|
|
||||||
public class GetBookRevisionsQueryHandler : IQueryHandler<GetBookRevisionsQuery, List<CreatorBookRevisionDto>>
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public GetBookRevisionsQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<FluentResults.Result<List<CreatorBookRevisionDto>>> Handle(GetBookRevisionsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Verify the book exists and belongs to this tenant/user to prevent cross-tenant data leaks
|
|
||||||
var bookExists = await dbContext.Books
|
|
||||||
.AnyAsync(b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId, cancellationToken);
|
|
||||||
|
|
||||||
if (!bookExists)
|
|
||||||
{
|
|
||||||
return FluentResults.Result.Fail<List<CreatorBookRevisionDto>>(new FluentResults.Error($"Book with ID '{request.BookId}' was not found."));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all revisions sorted chronologically
|
|
||||||
var revisions = await dbContext.BookRevisions
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(r => r.BookId == request.BookId)
|
|
||||||
.OrderByDescending(r => r.CreatedAt)
|
|
||||||
.Select(r => new CreatorBookRevisionDto(
|
|
||||||
r.Id,
|
|
||||||
r.VersionString,
|
|
||||||
r.IsPublished,
|
|
||||||
r.CreatedAt,
|
|
||||||
r.PublishedAt
|
|
||||||
))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return FluentResults.Result.Ok(revisions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Application.DTOs.Creator;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Creator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Query to load aggregated Creator Dashboard telemetry metrics and book listings.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="UserId">The ID of the creator requesting dashboard data.</param>
|
|
||||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
|
||||||
public record GetCreatorDashboardDataQuery(string UserId, string TenantId) : IQuery<CreatorDashboardDataDto>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler that executes projection-only LINQ queries to aggregate metrics and compute word counts
|
|
||||||
/// without loading raw chapter content into memory or tracking them in the EF Core Change Tracker.
|
|
||||||
/// </summary>
|
|
||||||
public class GetCreatorDashboardDataQueryHandler : IQueryHandler<GetCreatorDashboardDataQuery, CreatorDashboardDataDto>
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public GetCreatorDashboardDataQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<FluentResults.Result<CreatorDashboardDataDto>> Handle(GetCreatorDashboardDataQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Execute projection-only LINQ query. The heavy MarkdownContent is projected only as integer lengths.
|
|
||||||
var projectedBooks = await dbContext.Books
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(b => b.UserId == request.UserId && b.TenantId == request.TenantId)
|
|
||||||
.Select(b => new
|
|
||||||
{
|
|
||||||
b.Id,
|
|
||||||
b.Title,
|
|
||||||
LivePublishedRevision = b.LivePublishedRevision == null ? null : new CreatorBookRevisionDto(
|
|
||||||
b.LivePublishedRevision.Id,
|
|
||||||
b.LivePublishedRevision.VersionString,
|
|
||||||
b.LivePublishedRevision.IsPublished,
|
|
||||||
b.LivePublishedRevision.CreatedAt,
|
|
||||||
b.LivePublishedRevision.PublishedAt
|
|
||||||
),
|
|
||||||
CurrentDraftRevision = b.CurrentDraftRevision == null ? null : new CreatorBookRevisionDto(
|
|
||||||
b.CurrentDraftRevision.Id,
|
|
||||||
b.CurrentDraftRevision.VersionString,
|
|
||||||
b.CurrentDraftRevision.IsPublished,
|
|
||||||
b.CurrentDraftRevision.CreatedAt,
|
|
||||||
b.CurrentDraftRevision.PublishedAt
|
|
||||||
),
|
|
||||||
FirstChapterId = b.CurrentDraftRevision == null
|
|
||||||
? (Guid?)null
|
|
||||||
: b.CurrentDraftRevision.Chapters.OrderBy(c => c.SortOrder).Select(c => c.Id).FirstOrDefault(),
|
|
||||||
ChapterContentLengths = b.CurrentDraftRevision == null
|
|
||||||
? new List<int>()
|
|
||||||
: b.CurrentDraftRevision.Chapters.Select(c => c.MarkdownContent.Length).ToList()
|
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var booksList = new List<CreatorBookDto>();
|
|
||||||
int totalReads = 0;
|
|
||||||
int totalWords = 0;
|
|
||||||
|
|
||||||
foreach (var pBook in projectedBooks)
|
|
||||||
{
|
|
||||||
// Estimate word count (approx. 6 characters per word as a database-friendly standard length)
|
|
||||||
int wordCount = pBook.ChapterContentLengths.Sum(len => len / 6);
|
|
||||||
totalWords += wordCount;
|
|
||||||
|
|
||||||
// Generate deterministic simulated telemetry metrics scoped to this Book
|
|
||||||
int bookReads = Math.Abs(pBook.Id.GetHashCode() % 1000) + 120;
|
|
||||||
totalReads += bookReads;
|
|
||||||
|
|
||||||
var bookDto = new CreatorBookDto(
|
|
||||||
pBook.Id,
|
|
||||||
pBook.Title,
|
|
||||||
wordCount,
|
|
||||||
bookReads,
|
|
||||||
pBook.FirstChapterId,
|
|
||||||
pBook.LivePublishedRevision,
|
|
||||||
pBook.CurrentDraftRevision
|
|
||||||
);
|
|
||||||
|
|
||||||
booksList.Add(bookDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate aggregate dashboard metrics based on projected stats
|
|
||||||
int activeReaders = projectedBooks.Count == 0 ? 0 : Math.Abs(request.UserId.GetHashCode() % 15) + 3;
|
|
||||||
decimal grossRevenue = totalReads * 1.49m;
|
|
||||||
double avgReadTime = projectedBooks.Count == 0 ? 0 : Math.Round(totalWords / 250.0, 1); // standard 250 words per minute reading speed
|
|
||||||
|
|
||||||
var metrics = new DashboardMetricsDto(
|
|
||||||
totalReads,
|
|
||||||
avgReadTime,
|
|
||||||
activeReaders,
|
|
||||||
grossRevenue
|
|
||||||
);
|
|
||||||
|
|
||||||
return FluentResults.Result.Ok(new CreatorDashboardDataDto(metrics, booksList));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.Extensions.AI;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
|
||||||
using NexusReader.Application.Common;
|
|
||||||
using NexusReader.Application.DTOs.AI;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Intelligence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// MediatR query to request global intelligence hybrid Q&A context.
|
|
||||||
/// </summary>
|
|
||||||
public record GetGlobalIntelligenceQuery(string QueryText, string UserId, string TenantId = "global")
|
|
||||||
: IRequest<Result<IntelligenceResponse>>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request schema for global hybrid search queries.
|
|
||||||
/// </summary>
|
|
||||||
public record GetGlobalIntelligenceRequest(string QueryText);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response schema returning generated AI text, paywall status, and locked publishing details.
|
|
||||||
/// </summary>
|
|
||||||
public record IntelligenceResponse(
|
|
||||||
string ResponseText,
|
|
||||||
bool HasPaywall,
|
|
||||||
Guid? LockedBookId,
|
|
||||||
string? LockedBookTitle,
|
|
||||||
List<CitationDto>? Citations = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles <see cref="GetGlobalIntelligenceQuery"/> by performing local/global dual searches,
|
|
||||||
/// executing monetization rules, and invoking Chat AI with appropriate gating logic.
|
|
||||||
/// </summary>
|
|
||||||
public class GetGlobalIntelligenceQueryHandler : IRequestHandler<GetGlobalIntelligenceQuery, Result<IntelligenceResponse>>
|
|
||||||
{
|
|
||||||
private readonly IUserLibraryStore _userLibraryStore;
|
|
||||||
private readonly IVectorSearchStore _vectorSearchStore;
|
|
||||||
private readonly IChatClient _chatClient;
|
|
||||||
private readonly RagMonetizationOptions _options;
|
|
||||||
|
|
||||||
public GetGlobalIntelligenceQueryHandler(
|
|
||||||
IUserLibraryStore userLibraryStore,
|
|
||||||
IVectorSearchStore vectorSearchStore,
|
|
||||||
IChatClient chatClient,
|
|
||||||
IOptions<RagMonetizationOptions> options)
|
|
||||||
{
|
|
||||||
_userLibraryStore = userLibraryStore;
|
|
||||||
_vectorSearchStore = vectorSearchStore;
|
|
||||||
_chatClient = chatClient;
|
|
||||||
_options = options.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<IntelligenceResponse>> Handle(GetGlobalIntelligenceQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.QueryText))
|
|
||||||
{
|
|
||||||
return Result.Fail("Question cannot be empty.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Step A: Fetch whitelisted BookIds
|
|
||||||
var whitelistedBookIds = await _userLibraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
// Step B & C: Vector Dual-Search with Resilient Trapping
|
|
||||||
List<VectorChunk> globalChunks = new();
|
|
||||||
List<VectorChunk> localChunks = new();
|
|
||||||
double globalScore = 0.0;
|
|
||||||
double localScore = 0.0;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Execute searches
|
|
||||||
globalChunks = await _vectorSearchStore.SearchGlobalAsync(request.QueryText, request.TenantId, limit: 3, cancellationToken);
|
|
||||||
globalScore = globalChunks.Any() ? Math.Max(0.0, globalChunks.Max(c => c.Score)) : 0.0;
|
|
||||||
|
|
||||||
if (whitelistedBookIds.Any())
|
|
||||||
{
|
|
||||||
localChunks = await _vectorSearchStore.SearchLocalAsync(request.QueryText, request.TenantId, whitelistedBookIds, limit: 3, cancellationToken);
|
|
||||||
localScore = localChunks.Any() ? Math.Max(0.0, localChunks.Max(c => c.Score)) : 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Resilient Error Trapping: transform connectivity anomalies into domain-friendly errors
|
|
||||||
return Result.Fail(new Error("Serwer wyszukiwania semantycznego jest tymczasowo niedostępny. Spróbuj ponownie później.").CausedBy(ex));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step D: Evaluate Monetization Thresholds
|
|
||||||
bool triggerPaywall = false;
|
|
||||||
|
|
||||||
if (localScore == 0.0 && globalScore > _options.BaselineThreshold)
|
|
||||||
{
|
|
||||||
triggerPaywall = true;
|
|
||||||
}
|
|
||||||
else if ((globalScore - localScore) > _options.DeltaThreshold && globalScore > _options.UpgradeThreshold)
|
|
||||||
{
|
|
||||||
triggerPaywall = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var chosenChunks = triggerPaywall ? globalChunks : localChunks;
|
|
||||||
|
|
||||||
// Fetch book titles for citations/paywall metadata
|
|
||||||
var chunkEbookIds = chosenChunks
|
|
||||||
.Where(c => Guid.TryParse(c.EbookId, out _))
|
|
||||||
.Select(c => Guid.Parse(c.EbookId))
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var bookTitles = await _userLibraryStore.GetBookTitlesAsync(chunkEbookIds, cancellationToken);
|
|
||||||
|
|
||||||
// Step E: Identify locked book if paywall triggered
|
|
||||||
Guid? lockedBookId = null;
|
|
||||||
string? lockedBookTitle = null;
|
|
||||||
|
|
||||||
if (triggerPaywall && globalChunks.Any())
|
|
||||||
{
|
|
||||||
var topGlobalChunk = globalChunks.OrderByDescending(c => c.Score).First();
|
|
||||||
if (Guid.TryParse(topGlobalChunk.EbookId, out var parsedLockedId))
|
|
||||||
{
|
|
||||||
lockedBookId = parsedLockedId;
|
|
||||||
bookTitles.TryGetValue(parsedLockedId, out lockedBookTitle);
|
|
||||||
if (string.IsNullOrEmpty(lockedBookTitle))
|
|
||||||
{
|
|
||||||
lockedBookTitle = "Nieznana książka";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format context blocks for LLM
|
|
||||||
var relatedContexts = new List<string>();
|
|
||||||
foreach (var chunk in chosenChunks)
|
|
||||||
{
|
|
||||||
var sourceId = chunk.EbookId;
|
|
||||||
relatedContexts.Add($"[Source ID: {sourceId}] {chunk.Content}");
|
|
||||||
}
|
|
||||||
var contextBlocksText = string.Join("\n\n", relatedContexts);
|
|
||||||
|
|
||||||
// Build LLM prompts
|
|
||||||
var systemPrompt = "You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks.\n" +
|
|
||||||
"Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions.\n" +
|
|
||||||
"If the context does not contain the answer, say: 'I cannot answer this based on the provided book context.'";
|
|
||||||
|
|
||||||
if (triggerPaywall)
|
|
||||||
{
|
|
||||||
var localScorePercent = (int)Math.Round(localScore * 100);
|
|
||||||
var globalScorePercent = (int)Math.Round(globalScore * 100);
|
|
||||||
var resolvedTitle = lockedBookTitle ?? "Nieznana książka";
|
|
||||||
|
|
||||||
systemPrompt += $"\n\nCRITICAL: You are operating in TEASER mode. The user does not own the source document named '{resolvedTitle}'. You are strictly allowed to provide only a 1-sentence foundational definition or answer based on the context to prove the system knows the solution. DO NOT output code blocks, implementation details, or bullet points. You must immediately terminate your response with this exact token format: [PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}].";
|
|
||||||
}
|
|
||||||
|
|
||||||
var messages = new List<Microsoft.Extensions.AI.ChatMessage>
|
|
||||||
{
|
|
||||||
new(Microsoft.Extensions.AI.ChatRole.System, systemPrompt),
|
|
||||||
new(Microsoft.Extensions.AI.ChatRole.User, $"Context:\n{contextBlocksText}\n\nQuestion: {request.QueryText}")
|
|
||||||
};
|
|
||||||
|
|
||||||
var chatOptions = new ChatOptions
|
|
||||||
{
|
|
||||||
Temperature = 0.0f,
|
|
||||||
MaxOutputTokens = 1000
|
|
||||||
};
|
|
||||||
|
|
||||||
var chatResponse = await _chatClient.GetResponseAsync(messages, chatOptions, cancellationToken);
|
|
||||||
var responseText = chatResponse.Text?.Trim() ?? string.Empty;
|
|
||||||
|
|
||||||
// Ensure the paywall token is appended if LLM misses it in teaser mode
|
|
||||||
if (triggerPaywall)
|
|
||||||
{
|
|
||||||
var localScorePercent = (int)Math.Round(localScore * 100);
|
|
||||||
var globalScorePercent = (int)Math.Round(globalScore * 100);
|
|
||||||
var resolvedTitle = lockedBookTitle ?? "Nieznana książka";
|
|
||||||
var paywallToken = $"[PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}]";
|
|
||||||
|
|
||||||
if (!responseText.Contains("[PAYWALL_TRIGGER:"))
|
|
||||||
{
|
|
||||||
responseText = responseText.Trim() + " " + paywallToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build citations list
|
|
||||||
var citations = new List<CitationDto>();
|
|
||||||
foreach (var chunk in chosenChunks)
|
|
||||||
{
|
|
||||||
var sourceBookName = "Unknown";
|
|
||||||
if (Guid.TryParse(chunk.EbookId, out var parsedId) && bookTitles.TryGetValue(parsedId, out var title))
|
|
||||||
{
|
|
||||||
sourceBookName = title;
|
|
||||||
}
|
|
||||||
|
|
||||||
citations.Add(new CitationDto
|
|
||||||
{
|
|
||||||
CitationId = chunk.EbookId,
|
|
||||||
Snippet = chunk.Content,
|
|
||||||
SourceBook = sourceBookName,
|
|
||||||
Author = null,
|
|
||||||
PageNumber = null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Ok(new IntelligenceResponse(
|
|
||||||
ResponseText: responseText,
|
|
||||||
HasPaywall: triggerPaywall,
|
|
||||||
LockedBookId: lockedBookId,
|
|
||||||
LockedBookTitle: lockedBookTitle,
|
|
||||||
Citations: citations
|
|
||||||
));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Fail(new Error("Nieoczekiwany błąd serwera podczas przetwarzania zapytania.").CausedBy(ex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Recommendations;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// MediatR query to fetch contextual recommendations based on the user's active reading state.
|
|
||||||
/// </summary>
|
|
||||||
public record GetContextualRecommendationsQuery(string UserId)
|
|
||||||
: IRequest<Result<ContextualRecommendationResponse>>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO containing contextual recommendations.
|
|
||||||
/// </summary>
|
|
||||||
public record ContextualRecommendationResponse(List<RecommendationDto> Recommendations);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Individual contextual recommendation details.
|
|
||||||
/// </summary>
|
|
||||||
public record RecommendationDto(
|
|
||||||
string BookTitle,
|
|
||||||
string ChapterTitle,
|
|
||||||
int MatchPercentage,
|
|
||||||
bool IsPremiumUpsell,
|
|
||||||
Guid TargetBookId
|
|
||||||
);
|
|
||||||
@@ -18,16 +18,14 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
|
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var profile = await dbContext.Users
|
||||||
var userRaw = await dbContext.Users
|
|
||||||
.Where(u => u.Id == request.UserId)
|
.Where(u => u.Id == request.UserId)
|
||||||
.Select(u => new
|
.Select(u => new UserProfileDto
|
||||||
{
|
{
|
||||||
Email = u.Email ?? string.Empty,
|
Email = u.Email ?? string.Empty,
|
||||||
UserId = u.Id,
|
UserId = u.Id,
|
||||||
AITokensUsed = u.AITokensUsed,
|
AITokensUsed = u.AITokensUsed,
|
||||||
TenantIdString = u.TenantId,
|
TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty,
|
||||||
ThemePreference = u.ThemePreference,
|
|
||||||
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
||||||
{
|
{
|
||||||
Id = u.SubscriptionPlan.Id,
|
Id = u.SubscriptionPlan.Id,
|
||||||
@@ -35,17 +33,12 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
|
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
|
||||||
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
|
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
|
||||||
} : new SubscriptionPlanDto(),
|
} : new SubscriptionPlanDto(),
|
||||||
QuizResults = u.QuizResults.Select(q => new
|
AverageQuizScore = u.QuizResults.Any(q => q.TotalQuestions > 0)
|
||||||
{
|
? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100)
|
||||||
q.Score,
|
: 0,
|
||||||
q.TotalQuestions,
|
|
||||||
q.Id,
|
|
||||||
q.Topic,
|
|
||||||
q.Percentage,
|
|
||||||
q.CompletedDate
|
|
||||||
}).ToList(),
|
|
||||||
DisplayName = u.DisplayName,
|
DisplayName = u.DisplayName,
|
||||||
BooksReadCount = u.Ebooks.Count(),
|
BooksReadCount = u.Ebooks.Count(),
|
||||||
|
ConceptsMappedCount = dbContext.KnowledgeUnits.Count(k => k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)),
|
||||||
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
|
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
|
||||||
{
|
{
|
||||||
Id = e.Id,
|
Id = e.Id,
|
||||||
@@ -62,55 +55,7 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
Description = e.Description,
|
Description = e.Description,
|
||||||
IsReadyForReading = e.IsReadyForReading
|
IsReadyForReading = e.IsReadyForReading
|
||||||
}).FirstOrDefault(),
|
}).FirstOrDefault(),
|
||||||
Roles = dbContext.UserRoles
|
RecentQuizzes = u.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
|
||||||
.Where(ur => ur.UserId == u.Id)
|
|
||||||
.Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!)
|
|
||||||
.ToArray()
|
|
||||||
})
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (userRaw == null)
|
|
||||||
{
|
|
||||||
return Result.Fail("Profile not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var tenantId = userRaw.TenantIdString;
|
|
||||||
var mappedConcepts = await dbContext.KnowledgeUnits
|
|
||||||
.Where(k => k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId))
|
|
||||||
.OrderByDescending(k => k.CreatedAt)
|
|
||||||
.Take(6)
|
|
||||||
.Select(k => new MappedConceptDto
|
|
||||||
{
|
|
||||||
Id = k.Id,
|
|
||||||
Type = k.Type.ToString(),
|
|
||||||
Content = k.Content
|
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var conceptsMappedCount = await dbContext.KnowledgeUnits
|
|
||||||
.CountAsync(k => k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId), cancellationToken);
|
|
||||||
|
|
||||||
int averageQuizScore = 0;
|
|
||||||
var validQuizzes = userRaw.QuizResults.Where(q => q.TotalQuestions > 0).ToList();
|
|
||||||
if (validQuizzes.Count > 0)
|
|
||||||
{
|
|
||||||
averageQuizScore = (int)(validQuizzes.Average(q => (double)q.Score / q.TotalQuestions) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
var profile = new UserProfileDto
|
|
||||||
{
|
|
||||||
Email = userRaw.Email,
|
|
||||||
UserId = userRaw.UserId,
|
|
||||||
AITokensUsed = userRaw.AITokensUsed,
|
|
||||||
TenantId = userRaw.TenantIdString != null && userRaw.TenantIdString.Length == 36 ? new Guid(userRaw.TenantIdString) : Guid.Empty,
|
|
||||||
Plan = userRaw.Plan,
|
|
||||||
AverageQuizScore = averageQuizScore,
|
|
||||||
DisplayName = userRaw.DisplayName,
|
|
||||||
BooksReadCount = userRaw.BooksReadCount,
|
|
||||||
ThemePreference = userRaw.ThemePreference,
|
|
||||||
ConceptsMappedCount = conceptsMappedCount,
|
|
||||||
LastReadBook = userRaw.LastReadBook,
|
|
||||||
RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
|
|
||||||
{
|
{
|
||||||
Id = q.Id,
|
Id = q.Id,
|
||||||
Topic = q.Topic,
|
Topic = q.Topic,
|
||||||
@@ -119,9 +64,28 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
Percentage = q.Percentage,
|
Percentage = q.Percentage,
|
||||||
CompletedDate = q.CompletedDate
|
CompletedDate = q.CompletedDate
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
MappedConcepts = mappedConcepts,
|
MappedConcepts = dbContext.KnowledgeUnits
|
||||||
Roles = userRaw.Roles
|
.Where(k => k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId))
|
||||||
};
|
.OrderByDescending(k => k.CreatedAt)
|
||||||
|
.Take(6)
|
||||||
|
.Select(k => new MappedConceptDto
|
||||||
|
{
|
||||||
|
Id = k.Id,
|
||||||
|
Type = k.Type.ToString(),
|
||||||
|
Content = k.Content
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
Roles = dbContext.UserRoles
|
||||||
|
.Where(ur => ur.UserId == u.Id)
|
||||||
|
.Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!)
|
||||||
|
.ToArray()
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (profile == null)
|
||||||
|
{
|
||||||
|
return Result.Fail("Profile not found.");
|
||||||
|
}
|
||||||
|
|
||||||
return Result.Ok(profile);
|
return Result.Ok(profile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
namespace NexusReader.Application.Utilities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shared utility for parsing numeric segment identifiers from IDs like "seg-42".
|
|
||||||
/// Centralizes the parsing contract to avoid duplication across handlers and UI components.
|
|
||||||
/// </summary>
|
|
||||||
public static class SegmentIdParser
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts the numeric portion from a segment identifier string (e.g., "seg-42" → 42).
|
|
||||||
/// Returns 0 if the string is null, empty, or contains no digits.
|
|
||||||
/// </summary>
|
|
||||||
public static int Parse(string? id)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(id)) return 0;
|
|
||||||
var digits = new string(id.Where(char.IsDigit).ToArray());
|
|
||||||
return int.TryParse(digits, out var val) ? val : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-711
@@ -1,711 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace NexusReader.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260607104453_AddThemePreference")]
|
|
||||||
partial class AddThemePreference
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.7")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
|
||||||
.IsConcurrencyToken()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedName")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("RoleNameIndex");
|
|
||||||
|
|
||||||
b.ToTable("AspNetRoles", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("ClaimType")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ClaimValue")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("RoleId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("RoleId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetRoleClaims", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("ClaimType")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ClaimValue")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserClaims", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("LoginProvider")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ProviderKey")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ProviderDisplayName")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("LoginProvider", "ProviderKey");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserLogins", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("RoleId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("UserId", "RoleId");
|
|
||||||
|
|
||||||
b.HasIndex("RoleId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserRoles", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("LoginProvider")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Value")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("UserId", "LoginProvider", "Name");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserTokens", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("Authors");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTime>("AddedDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("AuthorId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("CoverUrl")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("FilePath")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<bool>("IsReadyForReading")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("LastChapter")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("LastChapterIndex")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastReadDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<double>("Progress")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("AuthorId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("Ebooks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Content")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<Guid?>("EbookId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("MetadataJson")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Version")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("EbookId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.ToTable("KnowledgeUnits");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("RelationType")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("SourceUnitId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("TargetUnitId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("SourceUnitId");
|
|
||||||
|
|
||||||
b.HasIndex("TargetUnitId");
|
|
||||||
|
|
||||||
b.ToTable("KnowledgeUnitLinks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("AITokenLimit")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("AITokensUsed")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("AccessFailedCount")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
|
||||||
.IsConcurrencyToken()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("DisplayName")
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<bool>("EmailConfirmed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastAiActionDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastReadAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("LastReadPageId")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<bool>("LockoutEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedEmail")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedUserName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("PhoneNumber")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<bool>("PhoneNumberConfirmed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("SecurityStamp")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("SubscriptionPlanId")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<int>("ThemePreference")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<bool>("TwoFactorEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("UserName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedEmail")
|
|
||||||
.HasDatabaseName("EmailIndex");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedUserName")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("UserNameIndex");
|
|
||||||
|
|
||||||
b.HasIndex("SubscriptionPlanId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUsers", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CompletedDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("Score")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Topic")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("TotalQuestions")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("QuizResults");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("ContentHash")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("JsonData")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ModelId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("OriginalText")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("PromptVersion")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.HasKey("ContentHash");
|
|
||||||
|
|
||||||
b.HasIndex("ContentHash")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.ToTable("SemanticKnowledgeCache");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<int>("AITokenLimit")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<bool>("IsUnlimitedTokens")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<decimal>("MonthlyPrice")
|
|
||||||
.HasColumnType("numeric");
|
|
||||||
|
|
||||||
b.Property<string>("PlanName")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("StripeProductId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("PlanName")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("SubscriptionPlans");
|
|
||||||
|
|
||||||
b.HasData(
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 1,
|
|
||||||
AITokenLimit = 5000,
|
|
||||||
IsUnlimitedTokens = false,
|
|
||||||
MonthlyPrice = 0m,
|
|
||||||
PlanName = "Free",
|
|
||||||
StripeProductId = "prod_Free789"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 2,
|
|
||||||
AITokenLimit = 10000,
|
|
||||||
IsUnlimitedTokens = false,
|
|
||||||
MonthlyPrice = 9.99m,
|
|
||||||
PlanName = "Basic",
|
|
||||||
StripeProductId = "prod_basic_placeholder"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 3,
|
|
||||||
AITokenLimit = 50000,
|
|
||||||
IsUnlimitedTokens = false,
|
|
||||||
MonthlyPrice = 19.99m,
|
|
||||||
PlanName = "Pro",
|
|
||||||
StripeProductId = "prod_pro_placeholder"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 4,
|
|
||||||
AITokenLimit = 1000000000,
|
|
||||||
IsUnlimitedTokens = true,
|
|
||||||
MonthlyPrice = 99.99m,
|
|
||||||
PlanName = "Enterprise",
|
|
||||||
StripeProductId = "prod_enterprise_placeholder"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("RoleId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("RoleId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
|
||||||
.WithMany("Ebooks")
|
|
||||||
.HasForeignKey("AuthorId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
|
||||||
.WithMany("Ebooks")
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Author");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("EbookId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
b.Navigation("Ebook");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
|
||||||
.WithMany("OutgoingLinks")
|
|
||||||
.HasForeignKey("SourceUnitId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
|
||||||
.WithMany("IncomingLinks")
|
|
||||||
.HasForeignKey("TargetUnitId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("SourceUnit");
|
|
||||||
|
|
||||||
b.Navigation("TargetUnit");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("SubscriptionPlanId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("SubscriptionPlan");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
|
||||||
.WithMany("QuizResults")
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Ebooks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("IncomingLinks");
|
|
||||||
|
|
||||||
b.Navigation("OutgoingLinks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Ebooks");
|
|
||||||
|
|
||||||
b.Navigation("QuizResults");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Pgvector;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace NexusReader.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddThemePreference : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Vector",
|
|
||||||
table: "SemanticKnowledgeCache");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Vector",
|
|
||||||
table: "KnowledgeUnits");
|
|
||||||
|
|
||||||
migrationBuilder.AlterDatabase()
|
|
||||||
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "ThemePreference",
|
|
||||||
table: "AspNetUsers",
|
|
||||||
type: "integer",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "ThemePreference",
|
|
||||||
table: "AspNetUsers");
|
|
||||||
|
|
||||||
migrationBuilder.AlterDatabase()
|
|
||||||
.Annotation("Npgsql:PostgresExtension:vector", ",,");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Vector>(
|
|
||||||
name: "Vector",
|
|
||||||
table: "SemanticKnowledgeCache",
|
|
||||||
type: "vector(1536)",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Vector>(
|
|
||||||
name: "Vector",
|
|
||||||
table: "KnowledgeUnits",
|
|
||||||
type: "vector(768)",
|
|
||||||
nullable: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-865
@@ -1,865 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace NexusReader.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260611183927_AddBookVersioningSupport")]
|
|
||||||
partial class AddBookVersioningSupport
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.7")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
|
||||||
.IsConcurrencyToken()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedName")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("RoleNameIndex");
|
|
||||||
|
|
||||||
b.ToTable("AspNetRoles", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("ClaimType")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ClaimValue")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("RoleId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("RoleId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetRoleClaims", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("ClaimType")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ClaimValue")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserClaims", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("LoginProvider")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ProviderKey")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ProviderDisplayName")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("LoginProvider", "ProviderKey");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserLogins", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("RoleId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("UserId", "RoleId");
|
|
||||||
|
|
||||||
b.HasIndex("RoleId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserRoles", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("LoginProvider")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Value")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("UserId", "LoginProvider", "Name");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserTokens", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("Authors");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid?>("CurrentDraftRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid?>("LivePublishedRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("CurrentDraftRevisionId");
|
|
||||||
|
|
||||||
b.HasIndex("LivePublishedRevisionId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("Books");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid>("BookId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsPublished")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("PublishedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("VersionString")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("BookId");
|
|
||||||
|
|
||||||
b.ToTable("BookRevisions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid>("BookRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("MarkdownContent")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("BookRevisionId");
|
|
||||||
|
|
||||||
b.ToTable("Chapters");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTime>("AddedDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("AuthorId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("CoverUrl")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("FilePath")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<bool>("IsReadyForReading")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("LastChapter")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("LastChapterIndex")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastReadDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<double>("Progress")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("AuthorId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("Ebooks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Content")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<Guid?>("EbookId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("MetadataJson")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Version")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("EbookId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.ToTable("KnowledgeUnits");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("RelationType")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("SourceUnitId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("TargetUnitId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("SourceUnitId");
|
|
||||||
|
|
||||||
b.HasIndex("TargetUnitId");
|
|
||||||
|
|
||||||
b.ToTable("KnowledgeUnitLinks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("AITokenLimit")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("AITokensUsed")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("AccessFailedCount")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
|
||||||
.IsConcurrencyToken()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("DisplayName")
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<bool>("EmailConfirmed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastAiActionDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastReadAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("LastReadPageId")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<bool>("LockoutEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedEmail")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedUserName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("PhoneNumber")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<bool>("PhoneNumberConfirmed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("SecurityStamp")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("SubscriptionPlanId")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<int>("ThemePreference")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<bool>("TwoFactorEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("UserName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedEmail")
|
|
||||||
.HasDatabaseName("EmailIndex");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedUserName")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("UserNameIndex");
|
|
||||||
|
|
||||||
b.HasIndex("SubscriptionPlanId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUsers", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CompletedDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("Score")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Topic")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("TotalQuestions")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("QuizResults");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("ContentHash")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("JsonData")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ModelId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("OriginalText")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("PromptVersion")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.HasKey("ContentHash");
|
|
||||||
|
|
||||||
b.HasIndex("ContentHash")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.ToTable("SemanticKnowledgeCache");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<int>("AITokenLimit")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<bool>("IsUnlimitedTokens")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<decimal>("MonthlyPrice")
|
|
||||||
.HasColumnType("numeric");
|
|
||||||
|
|
||||||
b.Property<string>("PlanName")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("StripeProductId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("PlanName")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("SubscriptionPlans");
|
|
||||||
|
|
||||||
b.HasData(
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 1,
|
|
||||||
AITokenLimit = 5000,
|
|
||||||
IsUnlimitedTokens = false,
|
|
||||||
MonthlyPrice = 0m,
|
|
||||||
PlanName = "Free",
|
|
||||||
StripeProductId = "prod_Free789"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 2,
|
|
||||||
AITokenLimit = 10000,
|
|
||||||
IsUnlimitedTokens = false,
|
|
||||||
MonthlyPrice = 9.99m,
|
|
||||||
PlanName = "Basic",
|
|
||||||
StripeProductId = "prod_basic_placeholder"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 3,
|
|
||||||
AITokenLimit = 50000,
|
|
||||||
IsUnlimitedTokens = false,
|
|
||||||
MonthlyPrice = 19.99m,
|
|
||||||
PlanName = "Pro",
|
|
||||||
StripeProductId = "prod_pro_placeholder"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 4,
|
|
||||||
AITokenLimit = 1000000000,
|
|
||||||
IsUnlimitedTokens = true,
|
|
||||||
MonthlyPrice = 99.99m,
|
|
||||||
PlanName = "Enterprise",
|
|
||||||
StripeProductId = "prod_enterprise_placeholder"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("RoleId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("RoleId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("CurrentDraftRevisionId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LivePublishedRevisionId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("CurrentDraftRevision");
|
|
||||||
|
|
||||||
b.Navigation("LivePublishedRevision");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
|
|
||||||
.WithMany("Revisions")
|
|
||||||
.HasForeignKey("BookId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Book");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
|
|
||||||
.WithMany("Chapters")
|
|
||||||
.HasForeignKey("BookRevisionId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("BookRevision");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
|
||||||
.WithMany("Ebooks")
|
|
||||||
.HasForeignKey("AuthorId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
|
||||||
.WithMany("Ebooks")
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Author");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("EbookId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
b.Navigation("Ebook");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
|
||||||
.WithMany("OutgoingLinks")
|
|
||||||
.HasForeignKey("SourceUnitId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
|
||||||
.WithMany("IncomingLinks")
|
|
||||||
.HasForeignKey("TargetUnitId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("SourceUnit");
|
|
||||||
|
|
||||||
b.Navigation("TargetUnit");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("SubscriptionPlanId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("SubscriptionPlan");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
|
||||||
.WithMany("QuizResults")
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Ebooks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Revisions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Chapters");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("IncomingLinks");
|
|
||||||
|
|
||||||
b.Navigation("OutgoingLinks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Ebooks");
|
|
||||||
|
|
||||||
b.Navigation("QuizResults");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace NexusReader.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddBookVersioningSupport : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "BookRevisions",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
BookId = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
VersionString = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
|
||||||
IsPublished = table.Column<bool>(type: "boolean", nullable: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
PublishedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_BookRevisions", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Books",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
|
||||||
TenantId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
|
||||||
UserId = table.Column<string>(type: "text", nullable: false),
|
|
||||||
CurrentDraftRevisionId = table.Column<Guid>(type: "uuid", nullable: true),
|
|
||||||
LivePublishedRevisionId = table.Column<Guid>(type: "uuid", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Books", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Books_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Books_BookRevisions_CurrentDraftRevisionId",
|
|
||||||
column: x => x.CurrentDraftRevisionId,
|
|
||||||
principalTable: "BookRevisions",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Books_BookRevisions_LivePublishedRevisionId",
|
|
||||||
column: x => x.LivePublishedRevisionId,
|
|
||||||
principalTable: "BookRevisions",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Restrict);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Chapters",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
BookRevisionId = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
|
||||||
MarkdownContent = table.Column<string>(type: "text", nullable: false),
|
|
||||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Chapters", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Chapters_BookRevisions_BookRevisionId",
|
|
||||||
column: x => x.BookRevisionId,
|
|
||||||
principalTable: "BookRevisions",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_BookRevisions_BookId",
|
|
||||||
table: "BookRevisions",
|
|
||||||
column: "BookId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Books_CurrentDraftRevisionId",
|
|
||||||
table: "Books",
|
|
||||||
column: "CurrentDraftRevisionId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Books_LivePublishedRevisionId",
|
|
||||||
table: "Books",
|
|
||||||
column: "LivePublishedRevisionId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Books_TenantId",
|
|
||||||
table: "Books",
|
|
||||||
column: "TenantId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Books_UserId",
|
|
||||||
table: "Books",
|
|
||||||
column: "UserId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Chapters_BookRevisionId",
|
|
||||||
table: "Chapters",
|
|
||||||
column: "BookRevisionId");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_BookRevisions_Books_BookId",
|
|
||||||
table: "BookRevisions",
|
|
||||||
column: "BookId",
|
|
||||||
principalTable: "Books",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_BookRevisions_Books_BookId",
|
|
||||||
table: "BookRevisions");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Chapters");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Books");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "BookRevisions");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
|||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using NexusReader.Data.Persistence;
|
using NexusReader.Data.Persistence;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ namespace NexusReader.Data.Migrations
|
|||||||
.HasAnnotation("ProductVersion", "10.0.7")
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||||
@@ -172,103 +174,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
b.ToTable("Authors");
|
b.ToTable("Authors");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid?>("CurrentDraftRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid?>("LivePublishedRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("CurrentDraftRevisionId");
|
|
||||||
|
|
||||||
b.HasIndex("LivePublishedRevisionId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("Books");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid>("BookId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsPublished")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("PublishedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("VersionString")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("BookId");
|
|
||||||
|
|
||||||
b.ToTable("BookRevisions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid>("BookRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("MarkdownContent")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("BookRevisionId");
|
|
||||||
|
|
||||||
b.ToTable("Chapters");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -359,6 +264,9 @@ namespace NexusReader.Data.Migrations
|
|||||||
b.Property<int>("Type")
|
b.Property<int>("Type")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Vector>("Vector")
|
||||||
|
.HasColumnType("vector(768)");
|
||||||
|
|
||||||
b.Property<string>("Version")
|
b.Property<string>("Version")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
@@ -480,11 +388,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)");
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
b.Property<int>("ThemePreference")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<bool>("TwoFactorEnabled")
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -577,6 +480,9 @@ namespace NexusReader.Data.Migrations
|
|||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)");
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<Vector>("Vector")
|
||||||
|
.HasColumnType("vector(1536)");
|
||||||
|
|
||||||
b.HasKey("ContentHash");
|
b.HasKey("ContentHash");
|
||||||
|
|
||||||
b.HasIndex("ContentHash")
|
b.HasIndex("ContentHash")
|
||||||
@@ -711,53 +617,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("CurrentDraftRevisionId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LivePublishedRevisionId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("CurrentDraftRevision");
|
|
||||||
|
|
||||||
b.Navigation("LivePublishedRevision");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
|
|
||||||
.WithMany("Revisions")
|
|
||||||
.HasForeignKey("BookId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Book");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
|
|
||||||
.WithMany("Chapters")
|
|
||||||
.HasForeignKey("BookRevisionId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("BookRevision");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||||
@@ -833,16 +692,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
b.Navigation("Ebooks");
|
b.Navigation("Ebooks");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Revisions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Chapters");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("IncomingLinks");
|
b.Navigation("IncomingLinks");
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
using NexusReader.Domain.Enums;
|
|
||||||
|
|
||||||
namespace NexusReader.Data.Persistence;
|
namespace NexusReader.Data.Persistence;
|
||||||
|
|
||||||
@@ -25,9 +24,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
||||||
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
||||||
public DbSet<Author> Authors => Set<Author>();
|
public DbSet<Author> Authors => Set<Author>();
|
||||||
public DbSet<Book> Books => Set<Book>();
|
|
||||||
public DbSet<BookRevision> BookRevisions => Set<BookRevision>();
|
|
||||||
public DbSet<Chapter> Chapters => Set<Chapter>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -47,10 +43,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
|
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
|
||||||
entity.Property(u => u.SubscriptionPlanId)
|
entity.Property(u => u.SubscriptionPlanId)
|
||||||
.HasDefaultValue(1);
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
entity.Property(u => u.ThemePreference)
|
|
||||||
.HasConversion<int>()
|
|
||||||
.HasDefaultValue(ThemeMode.System);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
modelBuilder.Entity<SubscriptionPlan>(entity =>
|
||||||
@@ -117,48 +109,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
entity.HasIndex(e => e.TenantId);
|
entity.HasIndex(e => e.TenantId);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Book>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(b => b.Id);
|
|
||||||
entity.HasIndex(b => b.TenantId);
|
|
||||||
entity.HasIndex(b => b.UserId);
|
|
||||||
|
|
||||||
entity.HasOne(e => e.User)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(e => e.UserId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity.HasMany(b => b.Revisions)
|
|
||||||
.WithOne(r => r.Book)
|
|
||||||
.HasForeignKey(r => r.BookId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity.HasOne(b => b.CurrentDraftRevision)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(b => b.CurrentDraftRevisionId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
entity.HasOne(b => b.LivePublishedRevision)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(b => b.LivePublishedRevisionId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<BookRevision>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(r => r.Id);
|
|
||||||
|
|
||||||
entity.HasMany(r => r.Chapters)
|
|
||||||
.WithOne(c => c.BookRevision)
|
|
||||||
.HasForeignKey(c => c.BookRevisionId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<Chapter>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(c => c.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Seed Subscription Plans with deterministic IDs
|
// Seed Subscription Plans with deterministic IDs
|
||||||
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
||||||
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
|
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -17,7 +16,6 @@ public static class DbInitializer
|
|||||||
using var scope = serviceProvider.CreateScope();
|
using var scope = serviceProvider.CreateScope();
|
||||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>();
|
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>();
|
||||||
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
||||||
var configuration = scope.ServiceProvider.GetService<IConfiguration>();
|
|
||||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -70,31 +68,7 @@ public static class DbInitializer
|
|||||||
SecurityStamp = Guid.NewGuid().ToString()
|
SecurityStamp = Guid.NewGuid().ToString()
|
||||||
};
|
};
|
||||||
|
|
||||||
var adminPassword = configuration?["Nexus:AdminPassword"]
|
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!");
|
||||||
?? configuration?["NEXUS_ADMIN_PASSWORD"]
|
|
||||||
?? Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD");
|
|
||||||
|
|
||||||
var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
|
|
||||||
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
|
|
||||||
?? "Development";
|
|
||||||
var isDevelopment = string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(adminPassword))
|
|
||||||
{
|
|
||||||
if (!isDevelopment)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"CRITICAL SECURITY ERROR: Admin password is NOT configured! " +
|
|
||||||
"In non-Development environments (e.g. Test/Production), the admin password must be explicitly set " +
|
|
||||||
"via configuration ('Nexus:AdminPassword' or 'NEXUS_ADMIN_PASSWORD') or environment variables. " +
|
|
||||||
"Seeding aborted to prevent insecure credentials fallback.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("[Seeder] WARNING: Admin password is not set. Falling back to default weak password 'Admin123!' in Development environment.");
|
|
||||||
adminPassword = "Admin123!";
|
|
||||||
}
|
|
||||||
|
|
||||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword);
|
|
||||||
|
|
||||||
dbContext.Users.Add(adminUser);
|
dbContext.Users.Add(adminUser);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
@@ -136,72 +110,6 @@ public static class DbInitializer
|
|||||||
{
|
{
|
||||||
Console.WriteLine("[Seeder] Admin user already exists.");
|
Console.WriteLine("[Seeder] Admin user already exists.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed Sample Authored Book for Creator Dashboard
|
|
||||||
var activeAdmin = await dbContext.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail);
|
|
||||||
if (activeAdmin != null)
|
|
||||||
{
|
|
||||||
if (!dbContext.Books.Any(b => b.UserId == activeAdmin.Id))
|
|
||||||
{
|
|
||||||
var sampleBookId = Guid.NewGuid();
|
|
||||||
var sampleBook = new Book
|
|
||||||
{
|
|
||||||
Id = sampleBookId,
|
|
||||||
Title = "Przewodnik po platformie Nexus",
|
|
||||||
UserId = activeAdmin.Id,
|
|
||||||
TenantId = activeAdmin.TenantId ?? "global"
|
|
||||||
};
|
|
||||||
dbContext.Books.Add(sampleBook);
|
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
var sampleRevisionId = Guid.NewGuid();
|
|
||||||
var sampleRevision = new BookRevision
|
|
||||||
{
|
|
||||||
Id = sampleRevisionId,
|
|
||||||
BookId = sampleBookId,
|
|
||||||
VersionString = "Working Draft",
|
|
||||||
IsPublished = false,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
dbContext.BookRevisions.Add(sampleRevision);
|
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
var sampleChapter1 = new Chapter
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookRevisionId = sampleRevisionId,
|
|
||||||
Title = "Rozdział 1: Wprowadzenie do Zen Mode",
|
|
||||||
MarkdownContent = @"# Zen Mode Editor
|
|
||||||
|
|
||||||
Welcome to your dedicated workspace. This premium panel supports Notion-like WYSIWYG editing.
|
|
||||||
|
|
||||||
## Features:
|
|
||||||
- **Zero Distraction**: Simple elevation and border framing.
|
|
||||||
- **GFM Tables**: Consistent cell padding and hover striping.
|
|
||||||
- **Clean Code Blocks**: Pre-rendered base64 font-loaded code-preview blocks.",
|
|
||||||
SortOrder = 1
|
|
||||||
};
|
|
||||||
|
|
||||||
var sampleChapter2 = new Chapter
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookRevisionId = sampleRevisionId,
|
|
||||||
Title = "Rozdział 2: Zabezpieczenia i XSS",
|
|
||||||
MarkdownContent = @"# Security Overview
|
|
||||||
|
|
||||||
This module provides Magic Number image signature checking and HtmlSanitizer filters.",
|
|
||||||
SortOrder = 2
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.Chapters.Add(sampleChapter1);
|
|
||||||
dbContext.Chapters.Add(sampleChapter2);
|
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
sampleBook.CurrentDraftRevisionId = sampleRevisionId;
|
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
Console.WriteLine("[Seeder] Sample authored book and chapters seeded for admin.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a Book metadata entry that references its decoupled revisions.
|
|
||||||
/// </summary>
|
|
||||||
public class Book
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(255)]
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(128)]
|
|
||||||
public string TenantId { get; set; } = "global";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string UserId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[ForeignKey(nameof(UserId))]
|
|
||||||
public virtual NexusUser? User { get; set; }
|
|
||||||
|
|
||||||
public Guid? CurrentDraftRevisionId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(CurrentDraftRevisionId))]
|
|
||||||
public virtual BookRevision? CurrentDraftRevision { get; set; }
|
|
||||||
|
|
||||||
public Guid? LivePublishedRevisionId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(LivePublishedRevisionId))]
|
|
||||||
public virtual BookRevision? LivePublishedRevision { get; set; }
|
|
||||||
|
|
||||||
public virtual ICollection<BookRevision> Revisions { get; set; } = new List<BookRevision>();
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encapsulates a snapshot or draft version of a Book's chapters.
|
|
||||||
/// </summary>
|
|
||||||
public class BookRevision
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public Guid BookId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(BookId))]
|
|
||||||
public virtual Book Book { get; set; } = null!;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(100)]
|
|
||||||
public string VersionString { get; set; } = "Working Draft";
|
|
||||||
|
|
||||||
public bool IsPublished { get; set; } = false;
|
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
public DateTime? PublishedAt { get; set; }
|
|
||||||
|
|
||||||
public virtual ICollection<Chapter> Chapters { get; set; } = new List<Chapter>();
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a chapter belonging strictly to a specific BookRevision.
|
|
||||||
/// </summary>
|
|
||||||
public class Chapter
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public Guid BookRevisionId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(BookRevisionId))]
|
|
||||||
public virtual BookRevision BookRevision { get; set; } = null!;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(255)]
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string MarkdownContent { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public int SortOrder { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using NexusReader.Domain.Enums;
|
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
namespace NexusReader.Domain.Entities;
|
||||||
|
|
||||||
@@ -66,9 +65,4 @@ public class NexusUser : IdentityUser
|
|||||||
/// Last read timestamp.
|
/// Last read timestamp.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? LastReadAt { get; set; }
|
public DateTime? LastReadAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// User's visual theme preference.
|
|
||||||
/// </summary>
|
|
||||||
public ThemeMode ThemePreference { get; set; } = ThemeMode.System;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace NexusReader.Domain.Enums;
|
|
||||||
|
|
||||||
public enum ThemeMode
|
|
||||||
{
|
|
||||||
System = 0,
|
|
||||||
Dark = 1,
|
|
||||||
LightSepia = 2
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace NexusReader.Domain.Exceptions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Custom domain exception thrown when a Book cannot be found by its ID.
|
|
||||||
/// </summary>
|
|
||||||
public class BookNotFoundException : Exception
|
|
||||||
{
|
|
||||||
public BookNotFoundException(Guid bookId)
|
|
||||||
: base($"Book with ID '{bookId}' was not found.")
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Configuration;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Settings for configuring allowed tags, attributes, CSS properties, and schemes in HtmlSanitizerService.
|
|
||||||
/// </summary>
|
|
||||||
public class HtmlSanitizerSettings
|
|
||||||
{
|
|
||||||
public const string SectionName = "HtmlSanitizer";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of HTML tags that are allowed.
|
|
||||||
/// If null or empty, the default allowed tags list is used.
|
|
||||||
/// </summary>
|
|
||||||
public List<string>? AllowedTags { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of HTML attributes that are allowed.
|
|
||||||
/// If null or empty, the default allowed attributes list is used.
|
|
||||||
/// </summary>
|
|
||||||
public List<string>? AllowedAttributes { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of CSS properties that are allowed.
|
|
||||||
/// If null or empty, the default allowed CSS properties list is used.
|
|
||||||
/// </summary>
|
|
||||||
public List<string>? AllowedCssProperties { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of URI schemes that are allowed (e.g. "http", "https").
|
|
||||||
/// If null or empty, the default allowed schemes list is used.
|
|
||||||
/// </summary>
|
|
||||||
public List<string>? AllowedSchemes { get; set; }
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using NexusReader.Application.Common;
|
|
||||||
using GeminiDotnet;
|
using GeminiDotnet;
|
||||||
using GeminiDotnet.Extensions.AI;
|
using GeminiDotnet.Extensions.AI;
|
||||||
using NexusReader.Data.Persistence;
|
using NexusReader.Data.Persistence;
|
||||||
@@ -55,24 +54,11 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// Qdrant Client registration
|
// Qdrant Client registration
|
||||||
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
||||||
var qdrantApiKey = configuration["Qdrant:ApiKey"];
|
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl)));
|
||||||
services.AddSingleton<QdrantClient>(sp =>
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(qdrantApiKey))
|
|
||||||
{
|
|
||||||
return new QdrantClient(new Uri(qdrantUrl), apiKey: qdrantApiKey);
|
|
||||||
}
|
|
||||||
return new QdrantClient(new Uri(qdrantUrl));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Neo4j Driver registration (supports optional authentication)
|
// Neo4j Driver registration
|
||||||
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
||||||
var neo4jUser = configuration["Neo4j:Username"];
|
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, AuthTokens.None));
|
||||||
var neo4jPass = configuration["Neo4j:Password"];
|
|
||||||
var neo4jAuth = !string.IsNullOrEmpty(neo4jUser)
|
|
||||||
? AuthTokens.Basic(neo4jUser, neo4jPass ?? string.Empty)
|
|
||||||
: AuthTokens.None;
|
|
||||||
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, neo4jAuth));
|
|
||||||
|
|
||||||
// Hangfire registration
|
// Hangfire registration
|
||||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||||
@@ -85,8 +71,6 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||||
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||||
services.Configure<RagMonetizationOptions>(configuration.GetSection(RagMonetizationOptions.SectionName));
|
|
||||||
services.Configure<HtmlSanitizerSettings>(configuration.GetSection(HtmlSanitizerSettings.SectionName));
|
|
||||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
||||||
@@ -133,16 +117,9 @@ public static class DependencyInjection
|
|||||||
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
|
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
|
||||||
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
|
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
|
||||||
services.AddScoped<IBookStorageService, BookStorageService>();
|
services.AddScoped<IBookStorageService, BookStorageService>();
|
||||||
services.AddScoped<IStorageService, LocalStorageService>();
|
|
||||||
services.AddSingleton<ISanitizerService, HtmlSanitizerService>();
|
|
||||||
|
|
||||||
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
||||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||||
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
|
|
||||||
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
|
|
||||||
services.AddScoped<IUserLibraryStore, UserLibraryStore>();
|
|
||||||
services.AddScoped<IUserReadingStateStore, UserReadingStateStore>();
|
|
||||||
services.AddScoped<IVectorSearchStore, VectorSearchStore>();
|
|
||||||
|
|
||||||
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
||||||
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
||||||
|
|||||||
@@ -28,8 +28,6 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
<PackageReference Include="Polly" />
|
<PackageReference Include="Polly" />
|
||||||
<PackageReference Include="Polly.Extensions.Http" />
|
<PackageReference Include="Polly.Extensions.Http" />
|
||||||
<PackageReference Include="HtmlSanitizer" />
|
|
||||||
<PackageReference Include="Markdig" />
|
|
||||||
<PackageReference Include="Qdrant.Client" />
|
<PackageReference Include="Qdrant.Client" />
|
||||||
<PackageReference Include="Stripe.net" />
|
<PackageReference Include="Stripe.net" />
|
||||||
<PackageReference Include="VersOne.Epub" />
|
<PackageReference Include="VersOne.Epub" />
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// EF Core implementation of <see cref="IConceptsMapReadRepository"/>.
|
|
||||||
/// Uses <see cref="IDbContextFactory{TContext}"/> for Blazor-safe scoped context creation.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class ConceptsMapReadRepository : IConceptsMapReadRepository
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public ConceptsMapReadRepository(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<string?> GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
|
|
||||||
var user = await dbContext.Users
|
|
||||||
.Where(u => u.Id == userId)
|
|
||||||
.Select(u => new { u.LastReadPageId })
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
return user?.LastReadPageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<List<KnowledgeUnit>> GetKnowledgeUnitsForBookAsync(
|
|
||||||
Guid bookId,
|
|
||||||
string tenantId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
|
|
||||||
return await dbContext.KnowledgeUnits
|
|
||||||
.Where(k => k.EbookId == bookId &&
|
|
||||||
(k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)))
|
|
||||||
.OrderBy(k => k.CreatedAt)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,12 +46,6 @@ internal sealed class EbookRepository : IEbookRepository
|
|||||||
_context.Ebooks.Add(ebook);
|
_context.Ebooks.Add(ebook);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Ebooks.FindAsync(new object[] { id }, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
=> _context.SaveChangesAsync(cancellationToken);
|
=> _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// EF Core implementation of <see cref="IQuizResultRepository"/>.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class QuizResultRepository : IQuizResultRepository
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _context;
|
|
||||||
|
|
||||||
public QuizResultRepository(AppDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void AddQuizResult(QuizResult quizResult)
|
|
||||||
{
|
|
||||||
_context.QuizResults.Add(quizResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// EF Core implementation of <see cref="IUserLibraryStore"/> using <see cref="AppDbContext"/>.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class UserLibraryStore : IUserLibraryStore
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _context;
|
|
||||||
|
|
||||||
public UserLibraryStore(AppDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Ebooks
|
|
||||||
.Where(e => e.UserId == userId)
|
|
||||||
.Select(e => e.Id)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (bookIds == null || !bookIds.Any())
|
|
||||||
{
|
|
||||||
return new Dictionary<Guid, string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await _context.Ebooks
|
|
||||||
.Where(e => bookIds.Contains(e.Id))
|
|
||||||
.ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// EF Core implementation of <see cref="IUserReadingStateStore"/>.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class UserReadingStateStore : IUserReadingStateStore
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public UserReadingStateStore(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
|
|
||||||
var userState = await dbContext.Users
|
|
||||||
.Where(u => u.Id == userId)
|
|
||||||
.Select(u => new
|
|
||||||
{
|
|
||||||
u.TenantId,
|
|
||||||
u.LastReadPageId,
|
|
||||||
LastReadBookId = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => (Guid?)e.Id).FirstOrDefault()
|
|
||||||
})
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (userState == null)
|
|
||||||
{
|
|
||||||
return (null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (userState.LastReadBookId, userState.LastReadPageId, userState.TenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
|
|
||||||
return await dbContext.KnowledgeUnits
|
|
||||||
.Where(ku => ku.Id == chapterId)
|
|
||||||
.Select(ku => ku.Content)
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.Extensions.AI;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Qdrant.Client;
|
|
||||||
using Qdrant.Client.Grpc;
|
|
||||||
using Polly;
|
|
||||||
using Polly.Registry;
|
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Infrastructure implementation of <see cref="IVectorSearchStore"/> utilizing <see cref="QdrantClient"/>
|
|
||||||
/// and <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> to execute semantic vector queries.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class VectorSearchStore : IVectorSearchStore
|
|
||||||
{
|
|
||||||
private readonly QdrantClient _qdrantClient;
|
|
||||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
|
||||||
private readonly ResiliencePipeline _retryPipeline;
|
|
||||||
private readonly ILogger<VectorSearchStore> _logger;
|
|
||||||
|
|
||||||
public VectorSearchStore(
|
|
||||||
QdrantClient qdrantClient,
|
|
||||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
|
||||||
ResiliencePipelineProvider<string> pipelineProvider,
|
|
||||||
ILogger<VectorSearchStore> logger)
|
|
||||||
{
|
|
||||||
_qdrantClient = qdrantClient;
|
|
||||||
_embeddingGenerator = embeddingGenerator;
|
|
||||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
|
||||||
var filter = BuildTenantFilter(tenantId);
|
|
||||||
|
|
||||||
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (whitelistedBookIds == null || !whitelistedBookIds.Any())
|
|
||||||
{
|
|
||||||
return new List<VectorChunk>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
|
||||||
var filter = BuildTenantFilter(tenantId);
|
|
||||||
|
|
||||||
var whitelistFilter = new Qdrant.Client.Grpc.Filter();
|
|
||||||
foreach (var bookId in whitelistedBookIds)
|
|
||||||
{
|
|
||||||
whitelistFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
|
||||||
{
|
|
||||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
|
||||||
{
|
|
||||||
Key = "ebookId",
|
|
||||||
Match = new Qdrant.Client.Grpc.Match { Text = bookId.ToString() }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = whitelistFilter });
|
|
||||||
|
|
||||||
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
|
|
||||||
var filter = BuildTenantFilter(tenantId);
|
|
||||||
|
|
||||||
// Exclude current book
|
|
||||||
filter.MustNot.Add(new Qdrant.Client.Grpc.Condition
|
|
||||||
{
|
|
||||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
|
||||||
{
|
|
||||||
Key = "ebookId",
|
|
||||||
Match = new Qdrant.Client.Grpc.Match { Text = excludeBookId.ToString() }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("[VectorSearchStore] Attempted to generate embedding from empty text. Returning zero vector.");
|
|
||||||
return Array.Empty<float>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var sw = Stopwatch.StartNew();
|
|
||||||
var response = await _retryPipeline.ExecuteAsync(async ct =>
|
|
||||||
await _embeddingGenerator.GenerateAsync(
|
|
||||||
new[] { text },
|
|
||||||
new EmbeddingGenerationOptions { Dimensions = 768 },
|
|
||||||
cancellationToken: ct), cancellationToken);
|
|
||||||
sw.Stop();
|
|
||||||
|
|
||||||
_logger.LogDebug("[VectorSearchStore] Embedding generated in {ElapsedMs}ms for text of {Length} chars.", sw.ElapsedMilliseconds, text.Length);
|
|
||||||
return response.First().Vector.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Qdrant.Client.Grpc.Filter BuildTenantFilter(string tenantId)
|
|
||||||
{
|
|
||||||
var filter = new Qdrant.Client.Grpc.Filter();
|
|
||||||
var tenantFilter = new Qdrant.Client.Grpc.Filter();
|
|
||||||
|
|
||||||
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
|
||||||
{
|
|
||||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
|
||||||
{
|
|
||||||
Key = "tenantId",
|
|
||||||
Match = new Qdrant.Client.Grpc.Match { Text = tenantId }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
|
|
||||||
{
|
|
||||||
Field = new Qdrant.Client.Grpc.FieldCondition
|
|
||||||
{
|
|
||||||
Key = "tenantId",
|
|
||||||
Match = new Qdrant.Client.Grpc.Match { Text = "global" }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = tenantFilter });
|
|
||||||
return filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<List<VectorChunk>> ExecuteSearchAsync(float[] queryVector, Qdrant.Client.Grpc.Filter filter, int limit, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (queryVector.Length == 0)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("[VectorSearchStore] Empty query vector — skipping Qdrant search.");
|
|
||||||
return new List<VectorChunk>();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
|
||||||
|
|
||||||
var sw = Stopwatch.StartNew();
|
|
||||||
var response = await _qdrantClient.SearchAsync(
|
|
||||||
collectionName: "knowledge_units",
|
|
||||||
vector: queryVector,
|
|
||||||
filter: filter,
|
|
||||||
limit: (ulong)limit,
|
|
||||||
cancellationToken: cancellationToken
|
|
||||||
);
|
|
||||||
sw.Stop();
|
|
||||||
_logger.LogInformation("[VectorSearchStore] Qdrant search returned {Count} results in {ElapsedMs}ms.", response.Count, sw.ElapsedMilliseconds);
|
|
||||||
|
|
||||||
return response.Select(point =>
|
|
||||||
{
|
|
||||||
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
|
|
||||||
var ebookId = point.Payload.TryGetValue("ebookId", out var ev) ? ev.StringValue : string.Empty;
|
|
||||||
var metadataJson = point.Payload.TryGetValue("metadataJson", out var mv) ? mv.StringValue : string.Empty;
|
|
||||||
var bookTitle = point.Payload.TryGetValue("bookTitle", out var btv) ? btv.StringValue : string.Empty;
|
|
||||||
var chapterTitle = point.Payload.TryGetValue("chapterTitle", out var ctv) ? ctv.StringValue : string.Empty;
|
|
||||||
|
|
||||||
return new VectorChunk(content, ebookId, point.Score, metadataJson, bookTitle, chapterTitle);
|
|
||||||
}).ToList();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "[VectorSearchStore] Qdrant search execution failed.");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
|
|
||||||
if (!exists)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' does not exist — creating.", collectionName);
|
|
||||||
await _qdrantClient.CreateCollectionAsync(
|
|
||||||
collectionName: collectionName,
|
|
||||||
vectorsConfig: new Qdrant.Client.Grpc.VectorParams
|
|
||||||
{
|
|
||||||
Size = 768,
|
|
||||||
Distance = Distance.Cosine
|
|
||||||
},
|
|
||||||
cancellationToken: cancellationToken
|
|
||||||
);
|
|
||||||
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' created successfully.", collectionName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Log concurrent creation conflicts (e.g., AlreadyExists gRPC status) but do not propagate.
|
|
||||||
_logger.LogWarning(ex, "[VectorSearchStore] Non-fatal error while ensuring collection '{CollectionName}' exists. Possible concurrent creation.", collectionName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
|
||||||
using NexusReader.Application.Queries.Recommendations;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles <see cref="GetContextualRecommendationsQuery"/> by discovering the active reading state,
|
|
||||||
/// performing semantic search using <see cref="IVectorSearchStore"/> with book exclusion, and mapping upsells.
|
|
||||||
/// </summary>
|
|
||||||
public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetContextualRecommendationsQuery, Result<ContextualRecommendationResponse>>
|
|
||||||
{
|
|
||||||
private readonly IUserReadingStateStore _readingStateStore;
|
|
||||||
private readonly IUserLibraryStore _libraryStore;
|
|
||||||
private readonly IVectorSearchStore _vectorSearchStore;
|
|
||||||
private readonly ILogger<GetContextualRecommendationsQueryHandler> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of <see cref="GetContextualRecommendationsQueryHandler"/>.
|
|
||||||
/// </summary>
|
|
||||||
public GetContextualRecommendationsQueryHandler(
|
|
||||||
IUserReadingStateStore readingStateStore,
|
|
||||||
IUserLibraryStore libraryStore,
|
|
||||||
IVectorSearchStore vectorSearchStore,
|
|
||||||
ILogger<GetContextualRecommendationsQueryHandler> logger)
|
|
||||||
{
|
|
||||||
_readingStateStore = readingStateStore;
|
|
||||||
_libraryStore = libraryStore;
|
|
||||||
_vectorSearchStore = vectorSearchStore;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<Result<ContextualRecommendationResponse>> Handle(GetContextualRecommendationsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(request.UserId))
|
|
||||||
{
|
|
||||||
return Result.Fail("UserId cannot be empty.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Step 1: Discover active reading state
|
|
||||||
var (ebookId, chapterId, tenantId) = await _readingStateStore.GetActiveReadingStateAsync(request.UserId, cancellationToken);
|
|
||||||
if (ebookId == null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("[Recommendations] No active reading state for user {UserId}. Returning empty list.", request.UserId);
|
|
||||||
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Fetch specific content associated with active ChapterId
|
|
||||||
string? chapterContent = null;
|
|
||||||
if (!string.IsNullOrEmpty(chapterId))
|
|
||||||
{
|
|
||||||
chapterContent = await _readingStateStore.GetChapterContentAsync(chapterId, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard: empty chapter content cannot produce a meaningful embedding
|
|
||||||
if (string.IsNullOrWhiteSpace(chapterContent))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("[Recommendations] Chapter content is empty for chapterId={ChapterId}. Returning empty list.", chapterId);
|
|
||||||
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Perform similarity search using IVectorSearchStore
|
|
||||||
var resolvedTenantId = tenantId ?? "global";
|
|
||||||
_logger.LogDebug("[Recommendations] Performing vector search for user {UserId}, book {EbookId}, tenant {TenantId}.", request.UserId, ebookId, resolvedTenantId);
|
|
||||||
|
|
||||||
var searchResults = await _vectorSearchStore.SearchGlobalExcludeAsync(
|
|
||||||
chapterContent,
|
|
||||||
resolvedTenantId,
|
|
||||||
ebookId.Value,
|
|
||||||
limit: 2,
|
|
||||||
cancellationToken: cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 4: Process recommendations and cross-reference owned books
|
|
||||||
var ownedBookIds = await _libraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken);
|
|
||||||
var recommendations = new List<RecommendationDto>();
|
|
||||||
|
|
||||||
foreach (var point in searchResults)
|
|
||||||
{
|
|
||||||
var targetEbookIdStr = point.EbookId;
|
|
||||||
if (!Guid.TryParse(targetEbookIdStr, out var targetEbookId))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// Load bookTitle from point
|
|
||||||
var bookTitle = point.BookTitle;
|
|
||||||
if (string.IsNullOrEmpty(bookTitle))
|
|
||||||
{
|
|
||||||
bookTitle = "Nieznana książka";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load chapterTitle from point or metadataJson
|
|
||||||
var chapterTitle = point.ChapterTitle;
|
|
||||||
if (string.IsNullOrEmpty(chapterTitle))
|
|
||||||
{
|
|
||||||
chapterTitle = "Wiedza z rozdziału";
|
|
||||||
if (!string.IsNullOrEmpty(point.MetadataJson))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(point.MetadataJson);
|
|
||||||
if (doc.RootElement.TryGetProperty("label", out var labelProp))
|
|
||||||
{
|
|
||||||
chapterTitle = labelProp.GetString() ?? chapterTitle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JsonException jsonEx)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(jsonEx, "[Recommendations] Failed to parse metadataJson for chunk with ebookId={EbookId}.", targetEbookIdStr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isPremiumUpsell = !ownedBookIds.Contains(targetEbookId);
|
|
||||||
var matchPercentage = (int)Math.Round(point.Score * 100);
|
|
||||||
|
|
||||||
recommendations.Add(new RecommendationDto(
|
|
||||||
BookTitle: bookTitle,
|
|
||||||
ChapterTitle: chapterTitle,
|
|
||||||
MatchPercentage: matchPercentage,
|
|
||||||
IsPremiumUpsell: isPremiumUpsell,
|
|
||||||
TargetBookId: targetEbookId
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("[Recommendations] Returning {Count} recommendations for user {UserId}.", recommendations.Count, request.UserId);
|
|
||||||
return Result.Ok(new ContextualRecommendationResponse(recommendations));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "[Recommendations] Downstream vector database or state query failed for user {UserId}.", request.UserId);
|
|
||||||
return Result.Fail(new Error("Downstream vector database or state query failed.").CausedBy(ex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,6 @@ public class BookStorageService : IBookStorageService
|
|||||||
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads");
|
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads");
|
||||||
EnsureDirectoryExists(uploadsFolder);
|
EnsureDirectoryExists(uploadsFolder);
|
||||||
|
|
||||||
fileName = SanitizeFileName(fileName);
|
|
||||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||||
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
|
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
|
||||||
|
|
||||||
@@ -53,7 +52,6 @@ public class BookStorageService : IBookStorageService
|
|||||||
var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
|
var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
|
||||||
EnsureDirectoryExists(coversFolder);
|
EnsureDirectoryExists(coversFolder);
|
||||||
|
|
||||||
fileName = SanitizeFileName(fileName);
|
|
||||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||||
var filePath = Path.Combine(coversFolder, uniqueFileName);
|
var filePath = Path.Combine(coversFolder, uniqueFileName);
|
||||||
|
|
||||||
@@ -65,25 +63,6 @@ public class BookStorageService : IBookStorageService
|
|||||||
return $"covers/{uniqueFileName}";
|
return $"covers/{uniqueFileName}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private string SanitizeFileName(string fileName)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(fileName)) return fileName;
|
|
||||||
|
|
||||||
var sanitized = fileName
|
|
||||||
.Replace('\u00A0', ' ')
|
|
||||||
.Replace('\u2007', ' ')
|
|
||||||
.Replace('\u200B', ' ')
|
|
||||||
.Replace('\u202F', ' ');
|
|
||||||
|
|
||||||
var invalidChars = Path.GetInvalidFileNameChars();
|
|
||||||
foreach (var c in invalidChars)
|
|
||||||
{
|
|
||||||
sanitized = sanitized.Replace(c, '_');
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnsureDirectoryExists(string path)
|
private void EnsureDirectoryExists(string path)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(path))
|
if (!Directory.Exists(path))
|
||||||
|
|||||||
@@ -18,19 +18,6 @@ public class EpubReaderService : IEpubReader
|
|||||||
private readonly ILogger<EpubReaderService> _logger;
|
private readonly ILogger<EpubReaderService> _logger;
|
||||||
private const int WordThreshold = 1000;
|
private const int WordThreshold = 1000;
|
||||||
|
|
||||||
private static readonly Regex ImageTagRegex = new(@"(?<before><img\b[^>]*?\bsrc=[""'])(?<src>[^""']*?)(?<after>[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
|
||||||
private static readonly Regex BodyMatchRegex = new(@"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
|
||||||
private static readonly Regex ParagraphMatchRegex = new(@"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>|<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
|
||||||
private static readonly Regex StyleScriptRegex = new(@"<(style|script)\b[^>]*>.*?</\1>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
|
||||||
private static readonly Regex WhitelistTagsRegex = new(@"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr|img)\b)[^>]+>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
|
||||||
private static readonly Regex StripAttributesRegex = new(@"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
|
||||||
private static readonly Regex ImgTagSanitizerRegex = new(@"<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
|
||||||
private static readonly Regex SrcAttributeRegex = new(@"\bsrc=[""'](?<src>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
|
||||||
private static readonly Regex AltAttributeRegex = new(@"\balt=[""'](?<alt>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
|
||||||
private static readonly Regex SvgImageTagRegex = new(@"<image\b(?<attrs>[^>]*?)>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
|
||||||
private static readonly Regex HrefAttributeRegex = new(@"\b(xlink:)?href=[""'](?<href>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
|
||||||
private static readonly Regex EmptyBlockRegex = new(@"^(</?(p|h[1-6]|ul|ol|li|blockquote|pre|div|span|br)\b[^>]*>| |\s)*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
|
||||||
|
|
||||||
public EpubReaderService(
|
public EpubReaderService(
|
||||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||||
ILogger<EpubReaderService> logger)
|
ILogger<EpubReaderService> logger)
|
||||||
@@ -93,9 +80,6 @@ public class EpubReaderService : IEpubReader
|
|||||||
|
|
||||||
var chapterContent = await chapterRef.ReadContentAsTextAsync();
|
var chapterContent = await chapterRef.ReadContentAsTextAsync();
|
||||||
|
|
||||||
// Rewrite relative image src URLs to use the server-side API endpoint
|
|
||||||
chapterContent = RewriteImageUrls(chapterContent, ebookId, chapterRef.FilePath);
|
|
||||||
|
|
||||||
// 3. Build content blocks
|
// 3. Build content blocks
|
||||||
var blocks = new List<ContentBlock>();
|
var blocks = new List<ContentBlock>();
|
||||||
int totalWordCount = 0;
|
int totalWordCount = 0;
|
||||||
@@ -105,7 +89,7 @@ public class EpubReaderService : IEpubReader
|
|||||||
foreach (var p in paragraphs)
|
foreach (var p in paragraphs)
|
||||||
{
|
{
|
||||||
var sanitizedContent = SanitizeParagraph(p);
|
var sanitizedContent = SanitizeParagraph(p);
|
||||||
if (string.IsNullOrWhiteSpace(sanitizedContent) || EmptyBlockRegex.IsMatch(sanitizedContent)) continue;
|
if (string.IsNullOrWhiteSpace(sanitizedContent)) continue;
|
||||||
|
|
||||||
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
|
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
|
||||||
|
|
||||||
@@ -158,177 +142,13 @@ public class EpubReaderService : IEpubReader
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<Result<byte[]>> GetEpubResourceAsync(
|
|
||||||
Guid ebookId,
|
|
||||||
string resourcePath,
|
|
||||||
string? userId = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
var ebook = await context.Ebooks
|
|
||||||
.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(
|
|
||||||
e => e.Id == ebookId && (userId == null || e.UserId == userId),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (ebook == null)
|
|
||||||
{
|
|
||||||
return Result.Fail($"Ebook '{ebookId}' not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullPath = ResolvePath(ebook.FilePath);
|
|
||||||
if (fullPath == null || !File.Exists(fullPath))
|
|
||||||
{
|
|
||||||
return Result.Fail("EPUB file not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
using var bookRef = await EpubReader.OpenBookAsync(fullPath);
|
|
||||||
|
|
||||||
var decodedPath = System.Net.WebUtility.UrlDecode(resourcePath);
|
|
||||||
if (decodedPath.Contains("..") || decodedPath.Contains(":") || decodedPath.StartsWith("/") || decodedPath.StartsWith("\\"))
|
|
||||||
{
|
|
||||||
return Result.Fail("Invalid resource path.");
|
|
||||||
}
|
|
||||||
|
|
||||||
decodedPath = decodedPath.Replace('\\', '/').TrimStart('/');
|
|
||||||
|
|
||||||
EpubLocalContentFileRef? targetFile = null;
|
|
||||||
if (bookRef.Content?.AllFiles?.Local != null)
|
|
||||||
{
|
|
||||||
foreach (var file in bookRef.Content.AllFiles.Local)
|
|
||||||
{
|
|
||||||
var filePath = file.FilePath?.Replace('\\', '/').TrimStart('/') ?? "";
|
|
||||||
var fileKey = file.Key?.Replace('\\', '/').TrimStart('/') ?? "";
|
|
||||||
if (filePath.Equals(decodedPath, StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
fileKey.Equals(decodedPath, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
targetFile = file;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetFile != null)
|
|
||||||
{
|
|
||||||
if (targetFile is EpubLocalByteContentFileRef byteFile)
|
|
||||||
{
|
|
||||||
byte[] bytes = await byteFile.ReadContentAsync();
|
|
||||||
return Result.Ok(bytes);
|
|
||||||
}
|
|
||||||
else if (targetFile is EpubLocalTextContentFileRef textFile)
|
|
||||||
{
|
|
||||||
string text = await textFile.ReadContentAsync();
|
|
||||||
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(text);
|
|
||||||
return Result.Ok(bytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Fail($"Resource '{resourcePath}' not found in EPUB.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to retrieve EPUB resource '{ResourcePath}' for ebook {EbookId}.", resourcePath, ebookId);
|
|
||||||
return Result.Fail(new Error($"Failed to retrieve EPUB resource: {ex.Message}").CausedBy(ex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string RewriteImageUrls(string html, Guid ebookId, string chapterPath)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(html)) return html;
|
|
||||||
|
|
||||||
var normalizedHtml = NormalizeSvgImageTags(html);
|
|
||||||
|
|
||||||
return ImageTagRegex.Replace(normalizedHtml, match =>
|
|
||||||
{
|
|
||||||
var rawSrc = match.Groups["src"].Value;
|
|
||||||
|
|
||||||
if (rawSrc.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return ""; // Completely block script execution in image src
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rawSrc.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
rawSrc.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
rawSrc.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return match.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
var resolvedPath = ResolveRelativePath(chapterPath, rawSrc);
|
|
||||||
var rewrittenSrc = $"/api/epub/{ebookId}/resource?path={System.Net.WebUtility.UrlEncode(resolvedPath)}";
|
|
||||||
return $"{match.Groups["before"].Value}{rewrittenSrc}{match.Groups["after"].Value}";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeSvgImageTags(string html)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(html)) return html;
|
|
||||||
|
|
||||||
return SvgImageTagRegex.Replace(html, match =>
|
|
||||||
{
|
|
||||||
var attrs = match.Groups["attrs"].Value;
|
|
||||||
|
|
||||||
if (SrcAttributeRegex.IsMatch(attrs))
|
|
||||||
{
|
|
||||||
return $"<img {attrs}>";
|
|
||||||
}
|
|
||||||
|
|
||||||
var hrefMatch = HrefAttributeRegex.Match(attrs);
|
|
||||||
if (hrefMatch.Success)
|
|
||||||
{
|
|
||||||
var hrefVal = hrefMatch.Groups["href"].Value;
|
|
||||||
var cleanedAttrs = HrefAttributeRegex.Replace(attrs, "");
|
|
||||||
return $"<img src=\"{hrefVal}\" {cleanedAttrs}>";
|
|
||||||
}
|
|
||||||
|
|
||||||
return match.Value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResolveRelativePath(string basePath, string relativePath)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(relativePath)) return string.Empty;
|
|
||||||
|
|
||||||
var decodedRelative = System.Net.WebUtility.UrlDecode(relativePath);
|
|
||||||
var baseDir = Path.GetDirectoryName(basePath) ?? "";
|
|
||||||
baseDir = baseDir.Replace('\\', '/');
|
|
||||||
|
|
||||||
var combined = Path.Combine(baseDir, decodedRelative).Replace('\\', '/');
|
|
||||||
var segments = combined.Split('/');
|
|
||||||
var stack = new Stack<string>();
|
|
||||||
|
|
||||||
foreach (var segment in segments)
|
|
||||||
{
|
|
||||||
if (segment == "." || string.IsNullOrEmpty(segment))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (segment == "..")
|
|
||||||
{
|
|
||||||
if (stack.Count > 0)
|
|
||||||
{
|
|
||||||
stack.Pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
stack.Push(segment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Join("/", stack.Reverse());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<string> ExtractParagraphs(string html)
|
private static List<string> ExtractParagraphs(string html)
|
||||||
{
|
{
|
||||||
var bodyMatch = BodyMatchRegex.Match(html);
|
var bodyMatch = Regex.Match(html, @"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||||
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
|
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
|
||||||
|
|
||||||
var paragraphs = new List<string>();
|
var paragraphs = new List<string>();
|
||||||
var matches = ParagraphMatchRegex.Matches(content);
|
var matches = Regex.Matches(content, @"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||||
|
|
||||||
foreach (Match match in matches)
|
foreach (Match match in matches)
|
||||||
{
|
{
|
||||||
@@ -345,20 +165,9 @@ public class EpubReaderService : IEpubReader
|
|||||||
|
|
||||||
private static string SanitizeParagraph(string html)
|
private static string SanitizeParagraph(string html)
|
||||||
{
|
{
|
||||||
var clean = StyleScriptRegex.Replace(html, "");
|
var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?</\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||||
clean = WhitelistTagsRegex.Replace(clean, "");
|
clean = Regex.Replace(clean, @"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b)[^>]+>", "", RegexOptions.IgnoreCase);
|
||||||
clean = StripAttributesRegex.Replace(clean, "<$1>");
|
clean = Regex.Replace(clean, @"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", "<$1>", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
// Securely sanitize img tags by keeping ONLY src and alt attributes to prevent XSS (onerror, onload, style, etc.)
|
|
||||||
clean = ImgTagSanitizerRegex.Replace(clean, m =>
|
|
||||||
{
|
|
||||||
var srcMatch = SrcAttributeRegex.Match(m.Value);
|
|
||||||
var altMatch = AltAttributeRegex.Match(m.Value);
|
|
||||||
var srcAttr = srcMatch.Success ? $" src=\"{srcMatch.Groups["src"].Value}\"" : "";
|
|
||||||
var altAttr = altMatch.Success ? $" alt=\"{altMatch.Groups["alt"].Value}\"" : "";
|
|
||||||
return $"<img{srcAttr}{altAttr} />";
|
|
||||||
});
|
|
||||||
|
|
||||||
clean = System.Net.WebUtility.HtmlDecode(clean);
|
clean = System.Net.WebUtility.HtmlDecode(clean);
|
||||||
return clean.Trim();
|
return clean.Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
using Ganss.Xss;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using NexusReader.Application.Abstractions.Services;
|
|
||||||
using NexusReader.Infrastructure.Configuration;
|
|
||||||
using Markdig;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Infrastructure implementation of ISanitizerService using the Ganss.Xss HtmlSanitizer library.
|
|
||||||
/// </summary>
|
|
||||||
public class HtmlSanitizerService : ISanitizerService
|
|
||||||
{
|
|
||||||
private readonly HtmlSanitizer _sanitizer;
|
|
||||||
private readonly MarkdownPipeline _pipeline;
|
|
||||||
|
|
||||||
public HtmlSanitizerService(IOptions<HtmlSanitizerSettings>? options = null)
|
|
||||||
{
|
|
||||||
_sanitizer = new HtmlSanitizer();
|
|
||||||
_pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
|
||||||
|
|
||||||
if (options?.Value != null)
|
|
||||||
{
|
|
||||||
var settings = options.Value;
|
|
||||||
|
|
||||||
if (settings.AllowedTags != null && settings.AllowedTags.Count > 0)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedTags.Clear();
|
|
||||||
foreach (var tag in settings.AllowedTags)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedTags.Add(tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.AllowedAttributes != null && settings.AllowedAttributes.Count > 0)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedAttributes.Clear();
|
|
||||||
foreach (var attr in settings.AllowedAttributes)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedAttributes.Add(attr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.AllowedCssProperties != null && settings.AllowedCssProperties.Count > 0)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedCssProperties.Clear();
|
|
||||||
foreach (var prop in settings.AllowedCssProperties)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedCssProperties.Add(prop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.AllowedSchemes != null && settings.AllowedSchemes.Count > 0)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedSchemes.Clear();
|
|
||||||
foreach (var scheme in settings.AllowedSchemes)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedSchemes.Add(scheme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Sanitize(string input)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(input))
|
|
||||||
{
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Translate raw Markdown input to HTML strictly before running HtmlSanitizer
|
|
||||||
var html = Markdown.ToHtml(input, _pipeline);
|
|
||||||
|
|
||||||
return _sanitizer.Sanitize(html).Trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,6 @@ using FluentResults;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MediatR;
|
|
||||||
using NexusReader.Application.Queries.Intelligence;
|
|
||||||
using Microsoft.ML.Tokenizers;
|
using Microsoft.ML.Tokenizers;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
@@ -35,10 +33,8 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
private readonly ILogger<KnowledgeService> _logger;
|
private readonly ILogger<KnowledgeService> _logger;
|
||||||
private readonly QdrantClient _qdrantClient;
|
private readonly QdrantClient _qdrantClient;
|
||||||
private readonly IDriver _neo4jDriver;
|
private readonly IDriver _neo4jDriver;
|
||||||
private readonly IMediator _mediator;
|
|
||||||
private const string PromptVersion = "1.7";
|
private const string PromptVersion = "1.7";
|
||||||
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
||||||
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
|
|
||||||
|
|
||||||
public KnowledgeService(
|
public KnowledgeService(
|
||||||
IChatClient chatClient,
|
IChatClient chatClient,
|
||||||
@@ -48,8 +44,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
IOptions<AiSettings> settings,
|
IOptions<AiSettings> settings,
|
||||||
ILogger<KnowledgeService> logger,
|
ILogger<KnowledgeService> logger,
|
||||||
QdrantClient qdrantClient,
|
QdrantClient qdrantClient,
|
||||||
IDriver neo4jDriver,
|
IDriver neo4jDriver)
|
||||||
IMediator mediator)
|
|
||||||
{
|
{
|
||||||
_chatClient = chatClient;
|
_chatClient = chatClient;
|
||||||
_embeddingGenerator = embeddingGenerator;
|
_embeddingGenerator = embeddingGenerator;
|
||||||
@@ -59,7 +54,6 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_qdrantClient = qdrantClient;
|
_qdrantClient = qdrantClient;
|
||||||
_neo4jDriver = neo4jDriver;
|
_neo4jDriver = neo4jDriver;
|
||||||
_mediator = mediator;
|
|
||||||
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
||||||
// a very reliable estimation for token usage in Gemini-based workloads.
|
// a very reliable estimation for token usage in Gemini-based workloads.
|
||||||
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
||||||
@@ -339,17 +333,6 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Retrieve the book's title from the database using EF Core
|
|
||||||
string bookTitle = "Nieznana książka";
|
|
||||||
if (ebookId.HasValue)
|
|
||||||
{
|
|
||||||
var ebook = await dbContext.Ebooks.FindAsync(new object[] { ebookId.Value }, cancellationToken);
|
|
||||||
if (ebook != null)
|
|
||||||
{
|
|
||||||
bookTitle = ebook.Title;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var contents = unitsToEmbed.Select(u => u.Content).ToList();
|
var contents = unitsToEmbed.Select(u => u.Content).ToList();
|
||||||
|
|
||||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
@@ -366,12 +349,6 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
var unitDto = unitsToEmbed[i];
|
var unitDto = unitsToEmbed[i];
|
||||||
var vector = embeddings[i].Vector.ToArray();
|
var vector = embeddings[i].Vector.ToArray();
|
||||||
|
|
||||||
string chapterTitle = "Wiedza z rozdziału";
|
|
||||||
if (unitDto.Metadata != null && unitDto.Metadata.TryGetValue("label", out var labelVal) && labelVal is string labelStr)
|
|
||||||
{
|
|
||||||
chapterTitle = labelStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
var point = new PointStruct
|
var point = new PointStruct
|
||||||
{
|
{
|
||||||
Id = GetDeterministicGuid(unitDto.Id),
|
Id = GetDeterministicGuid(unitDto.Id),
|
||||||
@@ -382,8 +359,6 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
["type"] = unitDto.Type ?? string.Empty,
|
["type"] = unitDto.Type ?? string.Empty,
|
||||||
["tenantId"] = tenantId,
|
["tenantId"] = tenantId,
|
||||||
["ebookId"] = ebookId?.ToString() ?? string.Empty,
|
["ebookId"] = ebookId?.ToString() ?? string.Empty,
|
||||||
["bookTitle"] = bookTitle,
|
|
||||||
["chapterTitle"] = chapterTitle,
|
|
||||||
["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata)
|
["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -479,7 +454,6 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
|
|
||||||
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
|
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await _collectionSemaphore.WaitAsync(cancellationToken);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
|
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
|
||||||
@@ -499,22 +473,10 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
|
||||||
if (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
(ex.InnerException != null && ex.InnerException.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' was already created by another thread.", collectionName);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName);
|
_logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
_collectionSemaphore.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Guid GetDeterministicGuid(string input)
|
private static Guid GetDeterministicGuid(string input)
|
||||||
{
|
{
|
||||||
@@ -613,9 +575,8 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
);
|
);
|
||||||
searchResult = response.ToList();
|
searchResult = response.ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "[KnowledgeService] Qdrant search failed during GetRelevantContextAsync. Returning empty search results.");
|
|
||||||
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
|
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,10 +594,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
summary = sumObj?.ToString();
|
summary = sumObj?.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch {}
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in RelevantContext mapping.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}";
|
var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}";
|
||||||
return new RelevantContext
|
return new RelevantContext
|
||||||
@@ -789,10 +747,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
{
|
{
|
||||||
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
|
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch {}
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in search library mapping.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var dto = new SemanticSearchResultDto
|
var dto = new SemanticSearchResultDto
|
||||||
@@ -916,8 +871,6 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
{
|
{
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
var units = await dbContext.KnowledgeUnits
|
var units = await dbContext.KnowledgeUnits
|
||||||
.Include(u => u.Ebook)
|
|
||||||
.ThenInclude(e => e.Author)
|
|
||||||
.Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId))
|
.Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u);
|
guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u);
|
||||||
@@ -963,10 +916,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
summary = sumObj?.ToString();
|
summary = sumObj?.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException jsonEx)
|
catch { }
|
||||||
{
|
|
||||||
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync source hydration.", sourceUnit.Id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
|
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
|
||||||
}
|
}
|
||||||
@@ -1004,10 +954,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
summary = sumObj?.ToString();
|
summary = sumObj?.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException jsonEx)
|
catch { }
|
||||||
{
|
|
||||||
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync target hydration.", targetUnit.Id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}";
|
targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}";
|
||||||
}
|
}
|
||||||
@@ -1039,10 +986,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
summary = sumObj?.ToString();
|
summary = sumObj?.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException jsonEx)
|
catch { }
|
||||||
{
|
|
||||||
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in fallback AskQuestionAsync.", sourceUnit.Id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
|
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
|
||||||
}
|
}
|
||||||
@@ -1138,6 +1082,19 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
{
|
{
|
||||||
citation.Author = unit.Ebook.Author.Name;
|
citation.Author = unit.Ebook.Author.Name;
|
||||||
}
|
}
|
||||||
|
else if (unit.EbookId.HasValue)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var eb = await dbContext.Ebooks.Include(e => e.Author).FirstOrDefaultAsync(e => e.Id == unit.EbookId.Value, cancellationToken);
|
||||||
|
if (eb?.Author != null)
|
||||||
|
{
|
||||||
|
citation.Author = eb.Author.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(unit.MetadataJson))
|
if (!string.IsNullOrEmpty(unit.MetadataJson))
|
||||||
{
|
{
|
||||||
@@ -1149,10 +1106,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
citation.PageNumber = pageVal;
|
citation.PageNumber = pageVal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch { }
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync citation mapping.", unit.Id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1211,12 +1165,6 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _mediator.Send(new GetGlobalIntelligenceQuery(queryText, userId, tenantId), cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int EstimateTokenCount(string text)
|
private int EstimateTokenCount(string text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(text)) return 0;
|
if (string.IsNullOrEmpty(text)) return 0;
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Infrastructure implementation of general storage utilizing local filesystem.
|
|
||||||
/// Files are saved in wwwroot/uploads/media.
|
|
||||||
/// </summary>
|
|
||||||
public class LocalStorageService : IStorageService
|
|
||||||
{
|
|
||||||
private readonly IWebHostEnvironment _environment;
|
|
||||||
|
|
||||||
public LocalStorageService(IWebHostEnvironment environment)
|
|
||||||
{
|
|
||||||
_environment = environment;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType)
|
|
||||||
{
|
|
||||||
using var stream = new MemoryStream(fileBytes);
|
|
||||||
return await UploadFileAsync(stream, fileName, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType)
|
|
||||||
{
|
|
||||||
var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads");
|
|
||||||
var resolvedMediaFolder = Path.GetFullPath(mediaFolder);
|
|
||||||
var folderWithSeparator = resolvedMediaFolder.EndsWith(Path.DirectorySeparatorChar)
|
|
||||||
? resolvedMediaFolder
|
|
||||||
: resolvedMediaFolder + Path.DirectorySeparatorChar;
|
|
||||||
|
|
||||||
if (!Directory.Exists(resolvedMediaFolder))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(resolvedMediaFolder);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean file name to prevent path traversal issues
|
|
||||||
var safeFileName = Path.GetFileName(fileName);
|
|
||||||
var uniqueFileName = $"{Guid.NewGuid()}_{safeFileName}";
|
|
||||||
var filePath = Path.Combine(resolvedMediaFolder, uniqueFileName);
|
|
||||||
|
|
||||||
// Guard against path traversal
|
|
||||||
var fullPath = Path.GetFullPath(filePath);
|
|
||||||
if (!fullPath.StartsWith(folderWithSeparator, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Path traversal detected.");
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var outputStream = new FileStream(fullPath, FileMode.Create))
|
|
||||||
{
|
|
||||||
await fileStream.CopyToAsync(outputStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the public web-relative URL
|
|
||||||
return $"/uploads/{uniqueFileName}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ using System.Threading;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.UI.Shared.Services;
|
|
||||||
|
|
||||||
namespace NexusReader.Maui.Infrastructure.Identity;
|
namespace NexusReader.Maui.Infrastructure.Identity;
|
||||||
|
|
||||||
@@ -56,14 +55,9 @@ public class MobileAuthenticationHeaderHandler : DelegatingHandler
|
|||||||
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
|
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
|
||||||
{
|
{
|
||||||
originalToken = tokenResult.Value;
|
originalToken = tokenResult.Value;
|
||||||
|
|
||||||
// Only attach the Bearer token if it is not expired
|
|
||||||
if (!JwtTokenValidator.IsExpired(originalToken))
|
|
||||||
{
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var response = await base.SendAsync(request, cancellationToken);
|
var response = await base.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ using NexusReader.Application;
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using NexusReader.Maui.Infrastructure.Logging;
|
using NexusReader.Maui.Infrastructure.Logging;
|
||||||
using NexusReader.Maui.Infrastructure.Identity;
|
using NexusReader.Maui.Infrastructure.Identity;
|
||||||
using NexusReader.Maui.Services;
|
|
||||||
|
|
||||||
namespace NexusReader.Maui;
|
namespace NexusReader.Maui;
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ public static class MauiProgram
|
|||||||
|
|
||||||
// Minimal Infrastructure
|
// Minimal Infrastructure
|
||||||
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
|
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
|
||||||
builder.Services.AddSingleton<INativeStorageService, NexusReader.Infrastructure.Mobile.Services.MauiStorageService>();
|
builder.Services.AddSingleton<INativeStorageService, MauiStorageService>();
|
||||||
|
|
||||||
// Minimal Identity (Safe Mode)
|
// Minimal Identity (Safe Mode)
|
||||||
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
|
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
|
||||||
@@ -57,26 +56,19 @@ public static class MauiProgram
|
|||||||
builder.Services.AddTransient<MobileAuthenticationHeaderHandler>();
|
builder.Services.AddTransient<MobileAuthenticationHeaderHandler>();
|
||||||
builder.Services.AddHttpClient("NexusAPI", client =>
|
builder.Services.AddHttpClient("NexusAPI", client =>
|
||||||
{
|
{
|
||||||
var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5104";
|
var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5000";
|
||||||
client.BaseAddress = new Uri(apiBaseUrl);
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
}).AddHttpMessageHandler<MobileAuthenticationHeaderHandler>();
|
}).AddHttpMessageHandler<MobileAuthenticationHeaderHandler>();
|
||||||
|
|
||||||
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
|
||||||
builder.Services.AddSingleton(featureSettings);
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<IUserPreferenceStore, MauiUserPreferenceStore>();
|
|
||||||
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
|
||||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||||
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
|
|
||||||
builder.Services.AddScoped<ILibraryStateService, LibraryStateService>();
|
|
||||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||||
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
using System.Net.Http;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using FluentResults;
|
|
||||||
using NexusReader.Application.DTOs.User;
|
|
||||||
using NexusReader.Domain.Enums;
|
|
||||||
using NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
namespace NexusReader.Maui.Services;
|
|
||||||
|
|
||||||
public class MauiUserPreferenceStore : IUserPreferenceStore
|
|
||||||
{
|
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
|
||||||
|
|
||||||
public MauiUserPreferenceStore(IHttpClientFactory httpClientFactory)
|
|
||||||
{
|
|
||||||
_httpClientFactory = httpClientFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpClient CreateClient() => _httpClientFactory.CreateClient("NexusAPI");
|
|
||||||
|
|
||||||
public async Task<Result> SaveThemePreferenceAsync(ThemeMode mode)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var client = CreateClient();
|
|
||||||
var response = await client.PostAsJsonAsync("identity/theme", new UpdateThemeRequest(mode));
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
return Result.Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
var error = await response.Content.ReadAsStringAsync();
|
|
||||||
return Result.Fail($"Failed to save cloud theme preference on mobile: {error}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Fail(new Error("Network error saving mobile theme preference to cloud.").CausedBy(ex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<ThemeMode>> GetThemePreferenceAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var client = CreateClient();
|
|
||||||
var response = await client.GetAsync("identity/profile");
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var profile = await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
|
||||||
return profile != null
|
|
||||||
? Result.Ok(profile.ThemePreference)
|
|
||||||
: Result.Fail("Failed to deserialize mobile profile response.");
|
|
||||||
}
|
|
||||||
return Result.Fail($"Failed to fetch theme preference from cloud on mobile: {response.ReasonPhrase}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Fail(new Error("Network error retrieving theme preference on mobile.").CausedBy(ex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"ApiSettings": {
|
"ApiSettings": {
|
||||||
"BaseUrl": "http://localhost:5104"
|
"BaseUrl": "https://localhost:5000"
|
||||||
},
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"Using": [
|
"Using": [
|
||||||
|
|||||||
@@ -7,33 +7,6 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
|
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var themeMode = localStorage.getItem('theme-mode');
|
|
||||||
var savedTheme = localStorage.getItem('theme');
|
|
||||||
var isLight = false;
|
|
||||||
|
|
||||||
if (themeMode === '2' || savedTheme === 'light') {
|
|
||||||
isLight = true;
|
|
||||||
} else if (themeMode === '1' || savedTheme === 'dark') {
|
|
||||||
isLight = false;
|
|
||||||
} else {
|
|
||||||
isLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLight) {
|
|
||||||
document.documentElement.classList.add('theme-light');
|
|
||||||
document.documentElement.classList.remove('theme-dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.add('theme-dark');
|
|
||||||
document.documentElement.classList.remove('theme-light');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Fail silently
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -100,7 +73,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="_content/NexusReader.UI.Shared/js/theme.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
<line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "share":
|
|
||||||
case "share-2":
|
case "share-2":
|
||||||
<circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
@@ -33,11 +32,10 @@
|
|||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||||
break;
|
break;
|
||||||
case "search":
|
case "search":
|
||||||
<circle cx="11" cy="11" r="8" fill="none" stroke="currentColor" stroke-width="2" />
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
||||||
break;
|
break;
|
||||||
case "message-square":
|
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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
break;
|
break;
|
||||||
case "diamond":
|
case "diamond":
|
||||||
<path d="M12 3L3 12L12 21L21 12L12 3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M12 3L3 12L12 21L21 12L12 3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
@@ -47,7 +45,6 @@
|
|||||||
<line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "book":
|
|
||||||
case "book-open":
|
case "book-open":
|
||||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
@@ -57,76 +54,38 @@
|
|||||||
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "settings":
|
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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
|
||||||
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
break;
|
||||||
case "bookmark":
|
case "bookmark":
|
||||||
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
|
||||||
break;
|
break;
|
||||||
case "target":
|
case "target":
|
||||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" />
|
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" />
|
||||||
<circle cx="12" cy="12" r="6" fill="none" stroke="currentColor" stroke-width="2" />
|
|
||||||
<circle cx="12" cy="12" r="2" fill="none" stroke="currentColor" stroke-width="2" />
|
|
||||||
break;
|
break;
|
||||||
case "trash":
|
case "trash":
|
||||||
<polyline points="3 6 5 6 21 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
<line x1="10" y1="11" x2="10" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
<line x1="14" y1="11" x2="14" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
break;
|
||||||
case "mail":
|
case "mail":
|
||||||
<rect width="20" height="16" x="2" y="4" rx="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
break;
|
||||||
case "lock":
|
case "lock":
|
||||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
break;
|
||||||
case "eye":
|
case "eye":
|
||||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" />
|
||||||
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
break;
|
||||||
case "eye-off":
|
case "eye-off":
|
||||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" />
|
||||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
<line x1="2" x2="22" y1="2" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
break;
|
||||||
case "arrow-left":
|
case "arrow-left":
|
||||||
<line x1="19" y1="12" x2="5" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
<polyline points="12 19 5 12 12 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
break;
|
||||||
case "arrow-right":
|
case "arrow-right":
|
||||||
<line x1="5" y1="12" x2="19" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
<polyline points="12 5 19 12 12 19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
|
||||||
case "edit":
|
|
||||||
case "edit-2":
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
break;
|
||||||
case "log-out":
|
case "log-out":
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "chevron-left":
|
|
||||||
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
|
||||||
case "chevron-right":
|
|
||||||
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
|
||||||
case "x":
|
|
||||||
case "close":
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
|
||||||
case "sun":
|
|
||||||
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
|
||||||
case "moon":
|
|
||||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
<!-- Fallback circle -->
|
<!-- Fallback circle -->
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.3 KiB |
@@ -1,9 +1,7 @@
|
|||||||
@namespace NexusReader.UI.Shared.Components.Atoms
|
@namespace NexusReader.UI.Shared.Components.Atoms
|
||||||
@using System.Text.RegularExpressions
|
@using System.Text.RegularExpressions
|
||||||
@using MediatR
|
|
||||||
@using NexusReader.Application.DTOs.AI
|
@using NexusReader.Application.DTOs.AI
|
||||||
@using NexusReader.Application.Queries.Library
|
@inject IKnowledgeService KnowledgeService
|
||||||
@inject IMediator Mediator
|
|
||||||
@inject IReaderNavigationService NavService
|
@inject IReaderNavigationService NavService
|
||||||
@inject IReaderInteractionService InteractionService
|
@inject IReaderInteractionService InteractionService
|
||||||
@inject NavigationManager NavManager
|
@inject NavigationManager NavManager
|
||||||
@@ -102,7 +100,6 @@
|
|||||||
private bool _isLoading;
|
private bool _isLoading;
|
||||||
private string? _searchError;
|
private string? _searchError;
|
||||||
private bool _isDropdownOpen;
|
private bool _isDropdownOpen;
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
private CancellationTokenSource? _searchCts;
|
private CancellationTokenSource? _searchCts;
|
||||||
|
|
||||||
@@ -143,18 +140,15 @@
|
|||||||
{
|
{
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_searchError = null;
|
_searchError = null;
|
||||||
if (!_disposed)
|
|
||||||
{
|
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
||||||
|
|
||||||
var result = await Mediator.Send(new SearchLibrarySemanticallyQuery(SearchValue, tenantId, Limit), token);
|
var result = await KnowledgeService.SearchLibrarySemanticallyAsync(SearchValue, tenantId, Limit, token);
|
||||||
if (token.IsCancellationRequested || _disposed) return;
|
if (token.IsCancellationRequested) return;
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
@@ -170,7 +164,7 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
if (!token.IsCancellationRequested && !_disposed)
|
if (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_results.Clear();
|
_results.Clear();
|
||||||
_searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania.";
|
_searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania.";
|
||||||
@@ -179,7 +173,7 @@
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (!token.IsCancellationRequested && !_disposed)
|
if (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
@@ -297,7 +291,6 @@
|
|||||||
IsFocused = false;
|
IsFocused = false;
|
||||||
// Delay slightly to allow click handlers on result cards to execute
|
// Delay slightly to allow click handlers on result cards to execute
|
||||||
await Task.Delay(200);
|
await Task.Delay(200);
|
||||||
if (_disposed) return;
|
|
||||||
_isDropdownOpen = false;
|
_isDropdownOpen = false;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
@@ -312,35 +305,29 @@
|
|||||||
|
|
||||||
private string HighlightQueryWords(string text, string query)
|
private string HighlightQueryWords(string text, string query)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(query))
|
||||||
return string.Empty;
|
return text;
|
||||||
|
|
||||||
var escapedText = System.Net.WebUtility.HtmlEncode(text);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
|
||||||
return escapedText;
|
|
||||||
|
|
||||||
var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Where(w => w.Length > 2)
|
.Where(w => w.Length > 2)
|
||||||
.Select(Regex.Escape);
|
.Select(Regex.Escape);
|
||||||
|
|
||||||
if (!words.Any())
|
if (!words.Any())
|
||||||
return escapedText;
|
return text;
|
||||||
|
|
||||||
var pattern = "(" + string.Join("|", words) + ")";
|
var pattern = "(" + string.Join("|", words) + ")";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Regex.Replace(escapedText, pattern, "<mark class=\"search-highlight\">$1</mark>", RegexOptions.IgnoreCase);
|
return Regex.Replace(text, pattern, "<mark class=\"search-highlight\">$1</mark>", RegexOptions.IgnoreCase);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return escapedText;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_disposed = true;
|
|
||||||
_searchCts?.Cancel();
|
_searchCts?.Cancel();
|
||||||
_searchCts?.Dispose();
|
_searchCts?.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Authorization
|
|
||||||
@inject PersistentComponentState ApplicationState
|
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
|
||||||
@implements IDisposable
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private PersistingComponentStateSubscription _subscription;
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
_subscription = ApplicationState.RegisterOnPersisting(PersistAuthenticationStateAsync);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PersistAuthenticationStateAsync()
|
|
||||||
{
|
|
||||||
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
|
||||||
var principal = authenticationState.User;
|
|
||||||
|
|
||||||
if (principal.Identity?.IsAuthenticated == true)
|
|
||||||
{
|
|
||||||
var email = principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? principal.Identity.Name;
|
|
||||||
var tenantId = principal.FindFirst("TenantId")?.Value ?? "global";
|
|
||||||
var roles = string.Join(",", principal.FindAll(System.Security.Claims.ClaimTypes.Role).Select(c => c.Value));
|
|
||||||
|
|
||||||
if (email != null)
|
|
||||||
{
|
|
||||||
ApplicationState.PersistAsJson("UserInfo", new UserInfo
|
|
||||||
{
|
|
||||||
Email = email,
|
|
||||||
TenantId = tenantId,
|
|
||||||
Roles = roles
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_subscription.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,559 +0,0 @@
|
|||||||
@using Microsoft.JSInterop
|
|
||||||
@implements IAsyncDisposable
|
|
||||||
@inject IJSRuntime JS
|
|
||||||
@inject HttpClient Http
|
|
||||||
@inject NexusReader.Application.Abstractions.Services.INativeStorageService StorageService
|
|
||||||
|
|
||||||
<div class="markdown-editor-container" style="height: @Height; width: @Width;">
|
|
||||||
@if (_showRestorationBanner)
|
|
||||||
{
|
|
||||||
<div class="restoration-banner">
|
|
||||||
<span class="banner-text">You have unsaved changes from an interrupted session.</span>
|
|
||||||
<div class="banner-actions">
|
|
||||||
<button type="button" class="banner-btn restore-btn" @onclick="RestoreBackupAsync">Restore</button>
|
|
||||||
<button type="button" class="banner-btn dismiss-btn" @onclick="DismissBackupAsync">Dismiss</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div @key="_editorRenderKey" id="@EditorId" class="milkdown-editor-wrapper"></div>
|
|
||||||
|
|
||||||
<div class="editor-footer">
|
|
||||||
<div class="status-indicator">
|
|
||||||
<span class="status-dot @StatusClass"></span>
|
|
||||||
<span class="status-text">@StatusText</span>
|
|
||||||
</div>
|
|
||||||
@if (ShowFetchButton)
|
|
||||||
{
|
|
||||||
<div class="editor-actions">
|
|
||||||
<button type="button" @onclick="FetchContentAsync" class="nexus-btn">
|
|
||||||
Fetch Markdown Content
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private string EditorId { get; set; } = $"milkdown-editor-{Guid.NewGuid():N}";
|
|
||||||
private Guid _editorRenderKey = Guid.NewGuid();
|
|
||||||
private readonly CancellationTokenSource _cts = new();
|
|
||||||
private IJSObjectReference? _module;
|
|
||||||
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
|
|
||||||
private string? _lastInitializedEditorId;
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
private enum SaveStatus
|
|
||||||
{
|
|
||||||
SavedToCloud,
|
|
||||||
Saving,
|
|
||||||
OfflineLocalBackup
|
|
||||||
}
|
|
||||||
|
|
||||||
private SaveStatus _status = SaveStatus.SavedToCloud;
|
|
||||||
private string _currentMarkdown = string.Empty;
|
|
||||||
private CancellationTokenSource? _debounceCts;
|
|
||||||
private readonly object _timerLock = new();
|
|
||||||
|
|
||||||
private bool _showRestorationBanner = false;
|
|
||||||
private NexusReader.Application.DTOs.Media.LocalBackupEnvelope? _pendingBackup;
|
|
||||||
private bool _hasRunStorageInit = false;
|
|
||||||
private bool _reinitializeEditor = false;
|
|
||||||
|
|
||||||
private string StatusClass => _status switch
|
|
||||||
{
|
|
||||||
SaveStatus.SavedToCloud => "saved",
|
|
||||||
SaveStatus.Saving => "saving",
|
|
||||||
SaveStatus.OfflineLocalBackup => "offline",
|
|
||||||
_ => "saved"
|
|
||||||
};
|
|
||||||
|
|
||||||
private string StatusText => _status switch
|
|
||||||
{
|
|
||||||
SaveStatus.SavedToCloud => "Saved to Cloud",
|
|
||||||
SaveStatus.Saving => "Saving...",
|
|
||||||
SaveStatus.OfflineLocalBackup => "Offline - Local Backup Only",
|
|
||||||
_ => "Saved to Cloud"
|
|
||||||
};
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public bool ShowFetchButton { get; set; } = true;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string InitialMarkdown { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<string> OnSave { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string Height { get; set; } = "500px";
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string Width { get; set; } = "100%";
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Guid ChapterId { get; set; } = Guid.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public DateTime? ServerTimestamp { get; set; }
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await base.OnInitializedAsync();
|
|
||||||
// Sweep keys and check restoration on init
|
|
||||||
await RunStorageSweepAndRestorationCheckAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Guid _prevChapterId = Guid.Empty;
|
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
|
||||||
{
|
|
||||||
await base.OnParametersSetAsync();
|
|
||||||
if (ChapterId != Guid.Empty && ChapterId != _prevChapterId)
|
|
||||||
{
|
|
||||||
_prevChapterId = ChapterId;
|
|
||||||
_hasRunStorageInit = false;
|
|
||||||
|
|
||||||
if (_module != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _module.InvokeVoidAsync("destroyEditor", EditorId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Error destroying old editor on chapter switch: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_reinitializeEditor = true;
|
|
||||||
EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
|
|
||||||
_editorRenderKey = Guid.NewGuid();
|
|
||||||
|
|
||||||
await RunStorageSweepAndRestorationCheckAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
var shouldInit = (firstRender || _reinitializeEditor) && (EditorId != _lastInitializedEditorId);
|
|
||||||
if (shouldInit)
|
|
||||||
{
|
|
||||||
_reinitializeEditor = false;
|
|
||||||
_lastInitializedEditorId = EditorId; // Set immediately before any async yield to prevent concurrent triggers
|
|
||||||
|
|
||||||
if (firstRender)
|
|
||||||
{
|
|
||||||
_dotNetHelper = DotNetObjectReference.Create(this);
|
|
||||||
// Retry if deferred during prerendering OnInitializedAsync
|
|
||||||
await RunStorageSweepAndRestorationCheckAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_module == null)
|
|
||||||
{
|
|
||||||
_module = await JS.InvokeAsync<IJSObjectReference>(
|
|
||||||
"import",
|
|
||||||
$"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Error initializing Milkdown editor: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunStorageSweepAndRestorationCheckAsync()
|
|
||||||
{
|
|
||||||
if (_hasRunStorageInit) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_hasRunStorageInit = true;
|
|
||||||
|
|
||||||
// Import wrapper module if not already loaded to access helper
|
|
||||||
if (_module == null)
|
|
||||||
{
|
|
||||||
_module = await JS.InvokeAsync<IJSObjectReference>(
|
|
||||||
"import",
|
|
||||||
$"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sweep and filter backup keys defensively
|
|
||||||
var keys = await _module.InvokeAsync<List<string>>("getBackupKeys");
|
|
||||||
if (keys != null)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
foreach (var key in keys)
|
|
||||||
{
|
|
||||||
// Strict defensive check before doing any JSON deserialization
|
|
||||||
if (!key.StartsWith("nexus-bkp-")) continue;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var backupResult = await StorageService.GetStringAsync(key);
|
|
||||||
if (backupResult.IsSuccess && !string.IsNullOrEmpty(backupResult.Value))
|
|
||||||
{
|
|
||||||
var envelope = System.Text.Json.JsonSerializer.Deserialize<NexusReader.Application.DTOs.Media.LocalBackupEnvelope>(
|
|
||||||
backupResult.Value,
|
|
||||||
NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope
|
|
||||||
);
|
|
||||||
if (envelope != null)
|
|
||||||
{
|
|
||||||
// Remove expired backups
|
|
||||||
if ((now - envelope.Timestamp).TotalDays > 7)
|
|
||||||
{
|
|
||||||
await StorageService.RemoveAsync(key);
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Boot-up Eviction: Deleted expired backup key {key}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Error sweeping key {key}: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restoration guard for this specific Chapter ID
|
|
||||||
var currentBackupKey = $"nexus-bkp-{ChapterId}";
|
|
||||||
var currentBackupResult = await StorageService.GetStringAsync(currentBackupKey);
|
|
||||||
if (currentBackupResult.IsSuccess && !string.IsNullOrEmpty(currentBackupResult.Value))
|
|
||||||
{
|
|
||||||
var envelope = System.Text.Json.JsonSerializer.Deserialize<NexusReader.Application.DTOs.Media.LocalBackupEnvelope>(
|
|
||||||
currentBackupResult.Value,
|
|
||||||
NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope
|
|
||||||
);
|
|
||||||
if (envelope != null)
|
|
||||||
{
|
|
||||||
var serverTime = ServerTimestamp ?? DateTime.MinValue;
|
|
||||||
if (envelope.Timestamp > serverTime && envelope.MarkdownContent != InitialMarkdown)
|
|
||||||
{
|
|
||||||
_pendingBackup = envelope;
|
|
||||||
_showRestorationBanner = true;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_hasRunStorageInit = false; // Reset to allow retry on client render
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Storage initialization deferred/failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RestoreBackupAsync()
|
|
||||||
{
|
|
||||||
if (_pendingBackup != null)
|
|
||||||
{
|
|
||||||
if (_module != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Prevent memory leak by cleaning up old instance in JS
|
|
||||||
await _module.InvokeVoidAsync("destroyEditor", EditorId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Error destroying old editor during restore: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
InitialMarkdown = _pendingBackup.MarkdownContent;
|
|
||||||
_showRestorationBanner = false;
|
|
||||||
_pendingBackup = null;
|
|
||||||
|
|
||||||
// Regenerate render key and ID to trigger clean Blazor element-level re-initialization
|
|
||||||
_reinitializeEditor = true;
|
|
||||||
EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
|
|
||||||
_editorRenderKey = Guid.NewGuid();
|
|
||||||
|
|
||||||
// Trigger an immediate background API autosave to synchronize the database with the restored content
|
|
||||||
_ = TriggerAutosaveAsync(InitialMarkdown, _cts.Token);
|
|
||||||
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DismissBackupAsync()
|
|
||||||
{
|
|
||||||
_showRestorationBanner = false;
|
|
||||||
_pendingBackup = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await StorageService.RemoveAsync($"nexus-bkp-{ChapterId}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Failed to dismiss backup from LocalStorage: {ex.Message}");
|
|
||||||
}
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task FetchContentAsync()
|
|
||||||
{
|
|
||||||
if (_module is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var markdown = await _module.InvokeAsync<string>("getMarkdownContent", EditorId);
|
|
||||||
|
|
||||||
if (OnSave.HasDelegate)
|
|
||||||
{
|
|
||||||
await OnSave.InvokeAsync(markdown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Error fetching markdown content: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[JSInvokable]
|
|
||||||
public async Task OnEditorContentChanged(string currentMarkdown)
|
|
||||||
{
|
|
||||||
_currentMarkdown = currentMarkdown;
|
|
||||||
|
|
||||||
// Structured JSON Envelope Pattern
|
|
||||||
var envelope = new NexusReader.Application.DTOs.Media.LocalBackupEnvelope
|
|
||||||
{
|
|
||||||
ChapterId = ChapterId,
|
|
||||||
Timestamp = DateTime.UtcNow,
|
|
||||||
MarkdownContent = currentMarkdown
|
|
||||||
};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var envelopeJson = System.Text.Json.JsonSerializer.Serialize(
|
|
||||||
envelope,
|
|
||||||
NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope
|
|
||||||
);
|
|
||||||
await StorageService.SaveStringAsync($"nexus-bkp-{ChapterId}", envelopeJson);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Failed to save backup to LocalStorage: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status indicator to Offline - Local Backup Only
|
|
||||||
_status = SaveStatus.OfflineLocalBackup;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
|
|
||||||
// Cancel pending timers thread-safely
|
|
||||||
CancellationTokenSource? ctsToCancel = null;
|
|
||||||
CancellationToken token;
|
|
||||||
lock (_timerLock)
|
|
||||||
{
|
|
||||||
if (_debounceCts != null)
|
|
||||||
{
|
|
||||||
ctsToCancel = _debounceCts;
|
|
||||||
_debounceCts = null;
|
|
||||||
}
|
|
||||||
_debounceCts = new CancellationTokenSource();
|
|
||||||
token = _debounceCts.Token; // Capture token synchronously under lock on UI thread
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctsToCancel != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ctsToCancel.CancelAsync();
|
|
||||||
ctsToCancel.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Error cancelling debounce timer: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start 5-second idle debounce timer
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(5000, token);
|
|
||||||
await TriggerAutosaveAsync(currentMarkdown, token);
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
// Task cancelled on new keystroke
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Debounce timer exception: {ex.Message}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task TriggerAutosaveAsync(string markdown, CancellationToken token)
|
|
||||||
{
|
|
||||||
if (token.IsCancellationRequested || _disposed) return;
|
|
||||||
|
|
||||||
_status = SaveStatus.Saving;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var request = new NexusReader.Application.DTOs.Media.AutosaveChapterRequest(markdown);
|
|
||||||
var response = await Http.PutAsJsonAsync(
|
|
||||||
$"/api/chapters/{ChapterId}/autosave",
|
|
||||||
request,
|
|
||||||
NexusReader.Application.Common.AppJsonContext.Default.AutosaveChapterRequest,
|
|
||||||
token
|
|
||||||
);
|
|
||||||
|
|
||||||
if (_disposed) return;
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
// Purge LocalStorage backup key on HTTP success
|
|
||||||
await StorageService.RemoveAsync($"nexus-bkp-{ChapterId}");
|
|
||||||
_status = SaveStatus.SavedToCloud;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_status = SaveStatus.OfflineLocalBackup;
|
|
||||||
var errorMsg = await response.Content.ReadAsStringAsync(token);
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Autosave HTTP error: {response.StatusCode} - {errorMsg}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
if (_disposed) return;
|
|
||||||
_status = SaveStatus.OfflineLocalBackup;
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_disposed) return;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
[JSInvokable]
|
|
||||||
public async Task<string> UploadImageFromJs(string filename, string contentType, IJSStreamReference streamRef)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const long maxFileSize = 5 * 1024 * 1024; // 5MB limit
|
|
||||||
using var stream = await streamRef.OpenReadStreamAsync(maxFileSize, _cts.Token);
|
|
||||||
using var memoryStream = new MemoryStream();
|
|
||||||
await stream.CopyToAsync(memoryStream, _cts.Token);
|
|
||||||
var fileBytes = memoryStream.ToArray();
|
|
||||||
|
|
||||||
using var content = new MultipartFormDataContent();
|
|
||||||
using var fileContent = new ByteArrayContent(fileBytes);
|
|
||||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
|
|
||||||
content.Add(fileContent, "file", filename);
|
|
||||||
|
|
||||||
var response = await Http.PostAsync("/api/media/upload", content, _cts.Token);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<NexusReader.Application.DTOs.Media.UploadResultDto>(
|
|
||||||
NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto, _cts.Token);
|
|
||||||
return result?.Url ?? "https://placehold.co/600x400?text=Upload+Failed";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var errorMsg = await response.Content.ReadAsStringAsync(_cts.Token);
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Image upload failed: {response.StatusCode} - {errorMsg}");
|
|
||||||
return "https://placehold.co/600x400?text=Upload+Failed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Exception during image upload: {ex.Message}");
|
|
||||||
return "https://placehold.co/600x400?text=Upload+Failed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
_disposed = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_cts.Cancel();
|
|
||||||
_cts.Dispose();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Fail silently
|
|
||||||
}
|
|
||||||
|
|
||||||
CancellationTokenSource? ctsToCancel = null;
|
|
||||||
lock (_timerLock)
|
|
||||||
{
|
|
||||||
if (_debounceCts != null)
|
|
||||||
{
|
|
||||||
ctsToCancel = _debounceCts;
|
|
||||||
_debounceCts = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctsToCancel != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ctsToCancel.Cancel();
|
|
||||||
ctsToCancel.Dispose();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Fail silently
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Always try to destroy via global window registration first to handle null _module
|
|
||||||
await JS.InvokeVoidAsync("milkdownWrapper.destroyEditor", EditorId);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Fallback to module if global is not set
|
|
||||||
if (_module is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _module.InvokeVoidAsync("destroyEditor", EditorId);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Fail silently
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_module is not null)
|
|
||||||
{
|
|
||||||
await _module.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
// Fail silently during circuit disconnection
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException)
|
|
||||||
{
|
|
||||||
// Fail silently if JS runtime/module is already disposed
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Unexpected error during JS cleanup: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_dotNetHelper?.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
.markdown-editor-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-editor-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
overflow: auto;
|
|
||||||
padding: 1.5rem;
|
|
||||||
position: relative;
|
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-editor-wrapper:focus-within {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 1px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 3. Bypassing Blazor CSS Isolation for Dynamic JS DOMs using ::deep */
|
|
||||||
::deep .milkdown-editor-wrapper .crepe {
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::deep .milkdown-editor-wrapper .milkdown {
|
|
||||||
background-color: var(--bg-surface) !important;
|
|
||||||
color: var(--text-main) !important;
|
|
||||||
font-family: var(--nexus-font-sans) !important;
|
|
||||||
border: none !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
|
|
||||||
/* Map Crepe's internal variables to our design tokens */
|
|
||||||
--crepe-color-background: var(--bg-surface);
|
|
||||||
--crepe-color-on-background: var(--text-main);
|
|
||||||
--crepe-color-surface: rgba(255, 255, 255, 0.03);
|
|
||||||
--crepe-color-surface-low: rgba(255, 255, 255, 0.01);
|
|
||||||
--crepe-color-primary: var(--accent);
|
|
||||||
--crepe-color-outline: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
::deep .milkdown-editor-wrapper .milkdown .editor {
|
|
||||||
color: var(--text-main) !important;
|
|
||||||
background: transparent !important;
|
|
||||||
outline: none !important;
|
|
||||||
padding: 0.5rem 0 !important;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style the buttons using variables from app.css */
|
|
||||||
.nexus-btn {
|
|
||||||
font-family: var(--nexus-font-sans);
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
background: var(--nexus-neon);
|
|
||||||
color: #000000;
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
filter: brightness(1.1);
|
|
||||||
box-shadow: 0 4px 15px var(--nexus-primary-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-btn:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stateful Status Indicator Footer */
|
|
||||||
.editor-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--bg-surface-low, rgba(255, 255, 255, 0.02));
|
|
||||||
border-radius: var(--radius-sm, 6px);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
margin-top: -0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted, #888888);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: 0 0 8px currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.saved {
|
|
||||||
color: #10B981; /* Green */
|
|
||||||
background-color: #10B981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.saving {
|
|
||||||
color: #F59E0B; /* Amber */
|
|
||||||
background-color: #F59E0B;
|
|
||||||
animation: status-pulse 1s infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.offline {
|
|
||||||
color: #EF4444; /* Red */
|
|
||||||
background-color: #EF4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Orange Restoration Warning Banner */
|
|
||||||
.restoration-banner {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
background: rgba(245, 158, 11, 0.1);
|
|
||||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
color: var(--text-main);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
animation: banner-fadeIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-text {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restore-btn {
|
|
||||||
background: #F59E0B;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restore-btn:hover {
|
|
||||||
background: #D97706;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dismiss-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dismiss-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes status-pulse {
|
|
||||||
0% { opacity: 0.4; transform: scale(0.9); }
|
|
||||||
100% { opacity: 1; transform: scale(1.1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes banner-fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(-5px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
@using NexusReader.Application.DTOs.AI
|
@using NexusReader.Application.DTOs.AI
|
||||||
@using Microsoft.Extensions.Logging
|
|
||||||
@inject IQuizStateService QuizState
|
@inject IQuizStateService QuizState
|
||||||
@inject KnowledgeCoordinator Coordinator
|
@inject KnowledgeCoordinator Coordinator
|
||||||
@inject ILogger<AiAssistantBubble> Logger
|
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<div class="ai-bubble-container">
|
<div class="ai-bubble-container">
|
||||||
@@ -136,7 +134,7 @@
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_displayedText = string.IsNullOrEmpty(Dialogue) ? "Błąd analizy." : Dialogue;
|
_displayedText = string.IsNullOrEmpty(Dialogue) ? "Błąd analizy." : Dialogue;
|
||||||
Logger.LogError(ex, "[AiAssistantBubble] Error fetching summary for block {BlockId}.", ContextBlockId);
|
Console.WriteLine($"[AiAssistantBubble] Error fetching summary: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
@using NexusReader.UI.Shared.Models
|
|
||||||
@using NexusReader.UI.Shared.Services
|
|
||||||
@using NexusReader.Application.DTOs.AI
|
|
||||||
@using NexusReader.Application.DTOs.User
|
|
||||||
@using Microsoft.Extensions.Logging
|
|
||||||
@using System.Net.Http.Json
|
|
||||||
@inject HttpClient Http
|
|
||||||
@inject ILibraryStateService LibraryStateService
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
@inject ILogger<AiResponseRenderer> Logger
|
|
||||||
|
|
||||||
<div class="message-row @(Message.Sender == "User" ? "user-row" : "ai-row")">
|
|
||||||
<div class="message-avatar" aria-hidden="true">
|
|
||||||
@if (Message.Sender == "User")
|
|
||||||
{
|
|
||||||
<i class="bi bi-person-fill"></i>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<i class="bi bi-robot"></i>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="message-bubble @GetBubbleClass()">
|
|
||||||
<div class="message-header">
|
|
||||||
<span class="sender-name">@Message.Sender</span>
|
|
||||||
<span class="message-time">@Message.Timestamp.ToString("HH:mm")</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="message-content">
|
|
||||||
@if (Message.Sender == "User")
|
|
||||||
{
|
|
||||||
<p>@Message.Text</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@if (_hasPaywall)
|
|
||||||
{
|
|
||||||
<div class="paywall-teaser" aria-hidden="true">
|
|
||||||
@foreach (var segment in ParseSegments(_displayTeaserText))
|
|
||||||
{
|
|
||||||
@if (segment.IsCitation)
|
|
||||||
{
|
|
||||||
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@RenderMarkdown(segment.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="upsell-card" role="alert" aria-live="polite">
|
|
||||||
<div class="upsell-header">
|
|
||||||
<span class="upsell-icon" aria-hidden="true">🔒</span>
|
|
||||||
<h4>Dostęp Premium Zablokowany</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="upsell-text">
|
|
||||||
Twoje zasoby odpowiadają na to pytanie w <strong>@_localScore%</strong>. W materiale <strong>'@_lockedBookTitle'</strong> znaleźliśmy odpowiedź dopasowaną w <strong>@_globalScore%</strong>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="upsell-actions">
|
|
||||||
@if (_isSimulatingPayment)
|
|
||||||
{
|
|
||||||
<button class="btn-upsell btn-primary loading" disabled aria-busy="true">
|
|
||||||
<div class="payment-spinner" aria-hidden="true"></div>
|
|
||||||
PRZETWARZANIE PŁATNOŚCI...
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button class="btn-upsell btn-primary" @onclick="HandlePurchase">
|
|
||||||
ODBLOKUJ PEŁNĄ TREŚĆ (29 PLN)
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<a href="/catalog?bookId=@_lockedBookId" class="btn-upsell btn-secondary">
|
|
||||||
Zobacz szczegóły w Katalogu
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="full-response">
|
|
||||||
@foreach (var segment in ParseSegments(GetCleanText()))
|
|
||||||
{
|
|
||||||
@if (segment.IsCitation)
|
|
||||||
{
|
|
||||||
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@RenderMarkdown(segment.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (_showSuccessBanner)
|
|
||||||
{
|
|
||||||
<div class="success-unlock-banner" role="status">
|
|
||||||
<span class="success-icon" aria-hidden="true">✓</span>
|
|
||||||
<span>Odblokowano pełną odpowiedź! Książka została dodana do Twojej biblioteki.</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public ChatMessage Message { get; set; } = default!;
|
|
||||||
[Parameter] public List<LastReadBookDto>? OwnedBooks { get; set; }
|
|
||||||
[Parameter] public EventCallback<Guid> OnUnlockRequested { get; set; }
|
|
||||||
|
|
||||||
private bool _hasPaywall;
|
|
||||||
private string _displayTeaserText = string.Empty;
|
|
||||||
private Guid _lockedBookId;
|
|
||||||
private string _lockedBookTitle = string.Empty;
|
|
||||||
private int _localScore;
|
|
||||||
private int _globalScore;
|
|
||||||
|
|
||||||
private bool _isUnlocked = false;
|
|
||||||
private bool _isSimulatingPayment = false;
|
|
||||||
private bool _showSuccessBanner = false;
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
|
||||||
{
|
|
||||||
base.OnParametersSet();
|
|
||||||
|
|
||||||
if (Message != null && Message.Sender != "User" && !_isUnlocked)
|
|
||||||
{
|
|
||||||
_hasPaywall = PaywallParser.TryParsePaywallTrigger(Message.Text, out _displayTeaserText, out _lockedBookId, out _lockedBookTitle, out _localScore, out _globalScore);
|
|
||||||
|
|
||||||
// Additional check: if user already owns the book, don't show the paywall
|
|
||||||
if (_hasPaywall && OwnedBooks != null)
|
|
||||||
{
|
|
||||||
var isOwned = OwnedBooks.Any(b =>
|
|
||||||
b.Id == _lockedBookId ||
|
|
||||||
(!string.IsNullOrEmpty(b.Title) && b.Title.Equals(_lockedBookTitle, StringComparison.OrdinalIgnoreCase)));
|
|
||||||
if (isOwned)
|
|
||||||
{
|
|
||||||
_hasPaywall = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_hasPaywall = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetCleanText()
|
|
||||||
{
|
|
||||||
if (Message == null) return string.Empty;
|
|
||||||
if (PaywallParser.TryParsePaywallTrigger(Message.Text, out var cleanText, out _, out _, out _, out _))
|
|
||||||
{
|
|
||||||
return cleanText;
|
|
||||||
}
|
|
||||||
return Message.Text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetBubbleClass()
|
|
||||||
{
|
|
||||||
if (Message.Sender == "User") return "user-bubble";
|
|
||||||
return _hasPaywall ? "ai-bubble paywalled-bubble" : "ai-bubble";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandlePurchase()
|
|
||||||
{
|
|
||||||
if (_isSimulatingPayment) return;
|
|
||||||
|
|
||||||
_isSimulatingPayment = true;
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
// Simulate payment gateway delay (1.5 seconds)
|
|
||||||
await Task.Delay(1500);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bookTitle = string.IsNullOrEmpty(_lockedBookTitle)
|
|
||||||
? "Architektura .NET 10 i Ekosystem Blazor"
|
|
||||||
: _lockedBookTitle;
|
|
||||||
|
|
||||||
// Call POST endpoint to persist the purchase
|
|
||||||
var response = await Http.PostAsJsonAsync("api/library/purchase", new { Title = bookTitle });
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
_isUnlocked = true;
|
|
||||||
_hasPaywall = false;
|
|
||||||
_showSuccessBanner = true;
|
|
||||||
|
|
||||||
// Fetch updated library list and update state manager
|
|
||||||
var updatedBooks = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
|
|
||||||
LibraryStateService.OwnedBooks = updatedBooks;
|
|
||||||
|
|
||||||
if (OnUnlockRequested.HasDelegate)
|
|
||||||
{
|
|
||||||
await OnUnlockRequested.InvokeAsync(_lockedBookId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Logger.LogWarning("[AiResponseRenderer] Purchase failed on server for book {BookId}.", _lockedBookId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "[AiResponseRenderer] Error processing purchase for book {BookId}.", _lockedBookId);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isSimulatingPayment = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<ResponseSegment> ParseSegments(string text)
|
|
||||||
{
|
|
||||||
var segments = new List<ResponseSegment>();
|
|
||||||
if (string.IsNullOrEmpty(text)) return segments;
|
|
||||||
|
|
||||||
var regex = new System.Text.RegularExpressions.Regex(
|
|
||||||
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
|
|
||||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
||||||
var matches = regex.Matches(text);
|
|
||||||
|
|
||||||
int lastIndex = 0;
|
|
||||||
foreach (System.Text.RegularExpressions.Match match in matches)
|
|
||||||
{
|
|
||||||
if (match.Index > lastIndex)
|
|
||||||
{
|
|
||||||
segments.Add(new ResponseSegment
|
|
||||||
{
|
|
||||||
Text = text.Substring(lastIndex, match.Index - lastIndex),
|
|
||||||
IsCitation = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var citationId = match.Groups[1].Success
|
|
||||||
? match.Groups[1].Value.Trim()
|
|
||||||
: match.Groups[2].Value.Trim();
|
|
||||||
|
|
||||||
segments.Add(new ResponseSegment
|
|
||||||
{
|
|
||||||
IsCitation = true,
|
|
||||||
CitationId = citationId
|
|
||||||
});
|
|
||||||
|
|
||||||
lastIndex = match.Index + match.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < text.Length)
|
|
||||||
{
|
|
||||||
segments.Add(new ResponseSegment
|
|
||||||
{
|
|
||||||
Text = text.Substring(lastIndex),
|
|
||||||
IsCitation = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
private MarkupString RenderMarkdown(string text)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
|
|
||||||
|
|
||||||
var html = System.Net.WebUtility.HtmlEncode(text);
|
|
||||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
|
|
||||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
|
|
||||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-code-block\"><code>$1</code></pre>");
|
|
||||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "<code class=\"nexus-inline-code\">$1</code>");
|
|
||||||
html = html.Replace("\n", "<br />");
|
|
||||||
|
|
||||||
return new MarkupString(html);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
.message-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 90%;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
animation: bubble-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-row {
|
|
||||||
align-self: flex-end;
|
|
||||||
margin-left: auto;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-row {
|
|
||||||
align-self: flex-start;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-avatar {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-row .message-avatar {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
color: var(--text-main);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
box-shadow: 0 0 10px var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-row .message-avatar {
|
|
||||||
background: linear-gradient(135deg, #005f38 0%, #004024 100%);
|
|
||||||
color: #e6fffa;
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
box-shadow: 0 0 10px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-bubble {
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
position: relative;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-size: 0.975rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-bubble {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-main);
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-bubble {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-main);
|
|
||||||
border-top-left-radius: 4px;
|
|
||||||
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paywalled-bubble {
|
|
||||||
border-color: var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sender-name {
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-time {
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Paragraph spacing */
|
|
||||||
.message-content p {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
}
|
|
||||||
.message-content p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Paywall Blur Styles */
|
|
||||||
.paywall-teaser {
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
|
||||||
mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
|
||||||
filter: blur(2px);
|
|
||||||
pointer-events: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Upsell Card */
|
|
||||||
.upsell-card {
|
|
||||||
background: var(--bg-base);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--accent-glow);
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
box-shadow: 0 8px 32px var(--accent-glow), 0 4px 12px var(--border);
|
|
||||||
animation: card-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upsell-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upsell-icon {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upsell-header h4 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upsell-text {
|
|
||||||
color: var(--text-main);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
margin: 0 0 1.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upsell-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-upsell {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
text-decoration: none;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
color: var(--bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: var(--accent);
|
|
||||||
opacity: 0.9;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 15px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:active:not(:disabled) {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:disabled {
|
|
||||||
background: var(--accent-glow);
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--accent-glow);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Success Banner */
|
|
||||||
.success-unlock-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
background: var(--accent-glow);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
animation: fade-in 0.5s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-icon {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Payment Spinner */
|
|
||||||
.payment-spinner {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keyframes */
|
|
||||||
@keyframes bubble-fade-in {
|
|
||||||
0% { opacity: 0; transform: translateY(12px) scale(0.98); }
|
|
||||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes card-slide-in {
|
|
||||||
0% { opacity: 0; transform: translateY(10px); }
|
|
||||||
100% { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
.theme-light .ai-row .message-avatar {
|
|
||||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
|
||||||
color: #ffffff;
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
|
||||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .user-row .message-avatar {
|
|
||||||
box-shadow: 0 2px 8px rgba(139, 130, 115, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .upsell-card {
|
|
||||||
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .btn-primary {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .btn-primary:hover:not(:disabled) {
|
|
||||||
background: #059669;
|
|
||||||
color: #ffffff;
|
|
||||||
opacity: 1;
|
|
||||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .btn-secondary {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .btn-secondary:hover {
|
|
||||||
background: rgba(16, 185, 129, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .paywall-teaser {
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
|
||||||
mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
@namespace NexusReader.UI.Shared.Components.Molecules
|
|
||||||
|
|
||||||
<div class="nexus-callout-box nexus-callout-@Type.ToString().ToLower() @Class">
|
|
||||||
@if (!string.IsNullOrEmpty(Title))
|
|
||||||
{
|
|
||||||
<div class="nexus-callout-header">
|
|
||||||
@if (Type == CalloutType.Warning || Type == CalloutType.Error)
|
|
||||||
{
|
|
||||||
<NexusIcon Name="warning" Size="16" Class="nexus-callout-icon" />
|
|
||||||
}
|
|
||||||
else if (Type == CalloutType.Success)
|
|
||||||
{
|
|
||||||
<NexusIcon Name="check" Size="16" Class="nexus-callout-icon" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<NexusIcon Name="info" Size="16" Class="nexus-callout-icon" />
|
|
||||||
}
|
|
||||||
<span class="nexus-callout-title">@Title</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="nexus-callout-body">
|
|
||||||
@ChildContent
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
public enum CalloutType
|
|
||||||
{
|
|
||||||
Info,
|
|
||||||
Warning,
|
|
||||||
Success,
|
|
||||||
Error
|
|
||||||
}
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public CalloutType Type { get; set; } = CalloutType.Info;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string? Title { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public RenderFragment? ChildContent { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string Class { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
.nexus-callout-box {
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin: 1.5rem 0 1.5rem 0;
|
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
font-family: var(--nexus-font-sans, sans-serif);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-left-width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light / Dark default support via variables or custom colors */
|
|
||||||
.nexus-callout-box {
|
|
||||||
background-color: rgba(255, 255, 255, 0.02);
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Info style */
|
|
||||||
.nexus-callout-info {
|
|
||||||
border-left-color: var(--nexus-neon, #00ff99);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Warning style */
|
|
||||||
.nexus-callout-warning {
|
|
||||||
border-left-color: #eab308; /* warning yellow */
|
|
||||||
background-color: rgba(234, 179, 8, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Success style */
|
|
||||||
.nexus-callout-success {
|
|
||||||
border-left-color: #10b981; /* success green */
|
|
||||||
background-color: rgba(16, 185, 129, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error style */
|
|
||||||
.nexus-callout-error {
|
|
||||||
border-left-color: #f43f5e; /* error red */
|
|
||||||
background-color: rgba(244, 63, 94, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-callout-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-callout-info .nexus-callout-header {
|
|
||||||
color: var(--nexus-neon, #00ff99);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-callout-warning .nexus-callout-header {
|
|
||||||
color: #eab308;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-callout-success .nexus-callout-header {
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-callout-error .nexus-callout-header {
|
|
||||||
color: #f43f5e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-callout-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-callout-body {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme support */
|
|
||||||
.theme-light .nexus-callout-box {
|
|
||||||
background-color: #fcfcfb;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
|
||||||
border-left-width: 4px;
|
|
||||||
color: #44403c;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.015);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .nexus-callout-info {
|
|
||||||
border-left-color: #10b981;
|
|
||||||
background-color: rgba(16, 185, 129, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .nexus-callout-info .nexus-callout-header {
|
|
||||||
color: #059669;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .nexus-callout-warning {
|
|
||||||
border-left-color: #d97706;
|
|
||||||
background-color: rgba(217, 119, 6, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .nexus-callout-warning .nexus-callout-header {
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .nexus-callout-success {
|
|
||||||
border-left-color: #10b981;
|
|
||||||
background-color: rgba(16, 185, 129, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .nexus-callout-success .nexus-callout-header {
|
|
||||||
color: #059669;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .nexus-callout-error {
|
|
||||||
border-left-color: #e11d48;
|
|
||||||
background-color: rgba(225, 29, 72, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .nexus-callout-error .nexus-callout-header {
|
|
||||||
color: #e11d48;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,46 +1,45 @@
|
|||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
@using NexusReader.Application.Abstractions.Services
|
@using NexusReader.Application.Abstractions.Services
|
||||||
@using Microsoft.Extensions.Logging
|
|
||||||
@using System.Linq
|
|
||||||
@inject IFocusModeService FocusMode
|
@inject IFocusModeService FocusMode
|
||||||
|
@inject IKnowledgeService KnowledgeService
|
||||||
@inject IIdentityService IdentityService
|
@inject IIdentityService IdentityService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject IThemeService ThemeService
|
|
||||||
@inject IKnowledgeService KnowledgeService
|
|
||||||
@inject ILogger<IntelligenceToolbar> Logger
|
|
||||||
@implements IDisposable
|
|
||||||
|
|
||||||
<aside class="intelligence-toolbar">
|
<aside class="intelligence-toolbar">
|
||||||
<div class="toolbar-top">
|
<div class="toolbar-top">
|
||||||
<button class="toolbar-item" @onclick='() => NavigationManager.NavigateTo("/")' title="Back to Dashboard">
|
<button class="toolbar-item" @onclick='() => NavigationManager.NavigateTo("/")' title="Back to Dashboard">
|
||||||
<NexusIcon Name="arrow-left" Size="20" />
|
<NexusIcon Name="arrow-left" Size="20" />
|
||||||
</button>
|
</button>
|
||||||
|
<button class="toolbar-item active" title="Chat">
|
||||||
@if (FocusMode.IsFocusModeActive)
|
|
||||||
{
|
|
||||||
<button class="toolbar-item active" @onclick="FocusMode.ToggleAsync" title="Focus Mode Active (Click to Exit)">
|
|
||||||
<NexusIcon Name="target" Size="20" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button class="toolbar-item active" @onclick="FocusMode.ToggleAsync" title="Chat Active (Click to Focus)">
|
|
||||||
<NexusIcon Name="message-square" Size="20" />
|
<NexusIcon Name="message-square" Size="20" />
|
||||||
</button>
|
</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>
|
||||||
|
<button class="toolbar-item danger" @onclick="HandleClearCache" title="Clear AI Cache">
|
||||||
|
<NexusIcon Name="trash" Size="20" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-bottom">
|
<div class="toolbar-bottom">
|
||||||
<div class="toolbar-separator"></div>
|
<button class="toolbar-item @(FocusMode.IsFocusModeActive ? "active focus-active" : "")"
|
||||||
|
@onclick="FocusMode.ToggleAsync" title="Focus Mode (F)">
|
||||||
<button class="toolbar-item" @onclick="ThemeService.ToggleTheme" title="Przełącz motyw">
|
<NexusIcon Name="target" Size="20" />
|
||||||
<NexusIcon Name="@(ThemeService.IsLightMode ? "sun" : "moon")" Size="20" />
|
|
||||||
</button>
|
</button>
|
||||||
|
<button class="toolbar-item" @onclick='() => NavigationManager.NavigateTo("/")' title="Global Hub">
|
||||||
<div class="toolbar-separator"></div>
|
<NexusIcon Name="layers" Size="20" />
|
||||||
|
</button>
|
||||||
<button class="toolbar-item clear-cache-item" @onclick="HandleClearCache" title="Wyczyść pamięć AI">
|
<button class="toolbar-item logout-item" @onclick="HandleLogout" title="Exit">
|
||||||
<NexusIcon Name="trash" Size="20" />
|
<NexusIcon Name="log-out" Size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -49,30 +48,29 @@
|
|||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
FocusMode.OnFocusModeChanged += HandleUpdate;
|
FocusMode.OnFocusModeChanged += HandleUpdate;
|
||||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleClearCache()
|
private async Task HandleClearCache()
|
||||||
{
|
{
|
||||||
Logger.LogInformation("[IntelligenceToolbar] Requesting cache clear...");
|
// For now, a simple console log confirm or just do it
|
||||||
|
Console.WriteLine("[IntelligenceToolbar] Requesting cache clear...");
|
||||||
var result = await KnowledgeService.ClearCacheAsync();
|
var result = await KnowledgeService.ClearCacheAsync();
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
Logger.LogInformation("[IntelligenceToolbar] Cache cleared successfully.");
|
Console.WriteLine("[IntelligenceToolbar] Cache cleared successfully!");
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
|
|
||||||
|
private async Task HandleLogout()
|
||||||
{
|
{
|
||||||
Logger.LogWarning("[IntelligenceToolbar] Cache clear failed: {Errors}", string.Join("; ", result.Errors.Select(e => e.Message)));
|
await IdentityService.LogoutAsync();
|
||||||
}
|
NavigationManager.NavigateTo("/account/logout-form", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
FocusMode.OnFocusModeChanged -= HandleUpdate;
|
FocusMode.OnFocusModeChanged -= HandleUpdate;
|
||||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,53 +71,26 @@
|
|||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-item.danger:hover {
|
||||||
|
color: #ff4d4d;
|
||||||
.toolbar-separator {
|
background: rgba(255, 77, 77, 0.1);
|
||||||
width: 24px;
|
|
||||||
height: 1px;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
margin: 0.2rem 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light mode overrides */
|
.toolbar-item.logout-item {
|
||||||
.theme-light .intelligence-toolbar {
|
margin-top: 1rem;
|
||||||
background: #f5f5f4;
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
padding-top: 1.5rem;
|
||||||
box-shadow: inset -2px 0 10px rgba(0, 0, 0, 0.02);
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0;
|
||||||
|
color: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-light .toolbar-item {
|
.toolbar-item.logout-item:hover {
|
||||||
color: #78716c;
|
color: #ff4d4d;
|
||||||
|
background: none;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(255, 77, 77, 0.4));
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-light .toolbar-item:hover {
|
|
||||||
color: #10b981;
|
|
||||||
background: rgba(16, 185, 129, 0.05);
|
|
||||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.1);
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .toolbar-item.active {
|
|
||||||
color: #10b981;
|
|
||||||
background: rgba(16, 185, 129, 0.08);
|
|
||||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.15);
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .toolbar-item.active::after {
|
|
||||||
background: #10b981;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .toolbar-item.focus-active {
|
|
||||||
color: #10b981;
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .toolbar-separator {
|
|
||||||
background: rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -335,175 +335,3 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light mode overrides */
|
|
||||||
.theme-light .knowledge-check {
|
|
||||||
background: #fafaf9;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .header-title {
|
|
||||||
color: #1c1917;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .question-text {
|
|
||||||
color: #44403c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .option-item {
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .option-item:hover {
|
|
||||||
background: #f5f5f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .option-item.selected {
|
|
||||||
border-color: #10b981;
|
|
||||||
background: rgba(16, 185, 129, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .option-letter {
|
|
||||||
color: #059669;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .option-text {
|
|
||||||
color: #292524;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .option-correct {
|
|
||||||
border-color: #10b981 !important;
|
|
||||||
background: rgba(16, 185, 129, 0.08) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .option-incorrect {
|
|
||||||
border-color: #f43f5e !important;
|
|
||||||
background: rgba(244, 63, 94, 0.08) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .option-revealed-correct {
|
|
||||||
border-color: #10b981 !important;
|
|
||||||
background: rgba(16, 185, 129, 0.06) !important;
|
|
||||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .loading-state.shimmer {
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.03), transparent);
|
|
||||||
color: #10b981;
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .submit-btn {
|
|
||||||
background: rgba(0, 0, 0, 0.03);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
||||||
color: #78716c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .submit-btn:not(:disabled) {
|
|
||||||
background: #10b981;
|
|
||||||
color: #ffffff;
|
|
||||||
border-color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .submitted-title {
|
|
||||||
color: #1c1917;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .submitted-text {
|
|
||||||
color: #78716c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .score-card {
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .score-num {
|
|
||||||
color: #10b981;
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .score-divider {
|
|
||||||
color: #e7e5e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .score-total {
|
|
||||||
color: #292524;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .score-percent {
|
|
||||||
color: #78716c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .reset-quiz-btn {
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
||||||
color: #44403c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .reset-quiz-btn:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.03);
|
|
||||||
border-color: #1c1917;
|
|
||||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .empty-title {
|
|
||||||
color: #1c1917;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .empty-text {
|
|
||||||
color: #78716c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .empty-icon-wrapper {
|
|
||||||
background: rgba(16, 185, 129, 0.02);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
|
||||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .empty-quiz-state:hover .empty-icon-wrapper {
|
|
||||||
background: rgba(16, 185, 129, 0.06);
|
|
||||||
border-color: rgba(16, 185, 129, 0.3);
|
|
||||||
box-shadow: 0 0 25px rgba(16, 185, 129, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .generate-quiz-btn {
|
|
||||||
background: rgba(16, 185, 129, 0.05);
|
|
||||||
border: 1px solid #10b981;
|
|
||||||
color: #10b981;
|
|
||||||
text-shadow: none;
|
|
||||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .generate-quiz-btn:not(:disabled):hover {
|
|
||||||
background: #10b981;
|
|
||||||
color: #ffffff;
|
|
||||||
box-shadow: 0 0 25px rgba(16, 185, 129, 0.2);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .generate-quiz-btn:disabled {
|
|
||||||
border-color: rgba(0, 0, 0, 0.1);
|
|
||||||
background: rgba(0, 0, 0, 0.02);
|
|
||||||
color: #a8a29e;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .success-icon-wrapper {
|
|
||||||
background: rgba(16, 185, 129, 0.05);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
|
||||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .success-glow {
|
|
||||||
color: #10b981;
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .neon-glow {
|
|
||||||
color: #10b981;
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,50 @@
|
|||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
@using NexusReader.UI.Shared.Models
|
|
||||||
@using NexusReader.Application.DTOs.AI
|
@using NexusReader.Application.DTOs.AI
|
||||||
@using Microsoft.Extensions.Logging
|
|
||||||
@inject KnowledgeCoordinator Coordinator
|
@inject KnowledgeCoordinator Coordinator
|
||||||
@inject IReaderInteractionService InteractionService
|
@inject IReaderInteractionService InteractionService
|
||||||
@inject IQuizStateService QuizService
|
|
||||||
@inject IJSRuntime JS
|
|
||||||
@inject ILogger<SelectionAiPanel> Logger
|
|
||||||
|
|
||||||
@if (IsVisible)
|
@if (IsVisible)
|
||||||
{
|
{
|
||||||
<div class="selection-ai-panel @(_positionBelow ? "below" : "")" style="@_style">
|
<div class="selection-ai-panel expanded @(PositionBelow ? "below" : "")" style="@PanelStyle">
|
||||||
<button id="summary-btn" class="toolbar-btn primary @(IsLoadingSummary ? "loading" : "") @(IsAnyLoading ? "disabled cursor-not-allowed opacity-50" : "")"
|
<div class="ai-bubble">
|
||||||
disabled="@IsAnyLoading"
|
<div class="ai-avatar">
|
||||||
@onclick="RequestSummaryAsync">
|
<div class="avatar-ring"></div>
|
||||||
@if (IsLoadingSummary)
|
<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">
|
||||||
|
@if (IsLoading)
|
||||||
{
|
{
|
||||||
<svg class="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style="animation: spin 1s linear infinite; width: 14px; height: 14px; color: currentColor; display: inline-block; margin-right: 4px;">
|
<div class="loading-state">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" style="opacity: 0.25;"></circle>
|
<div class="shimmer">Skanowanie fragmentu...</div>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" style="opacity: 0.75;"></path>
|
</div>
|
||||||
</svg>
|
}
|
||||||
<span class="btn-text">Podsumowywanie...</span>
|
else if (Packet != null)
|
||||||
|
{
|
||||||
|
<div class="summary-box">
|
||||||
|
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@Packet.Summary</NexusTypography>
|
||||||
|
</div>
|
||||||
|
<div class="ai-actions">
|
||||||
|
<button class="action-btn neon-border" @onclick="GenerateFullQuiz">Generuj Quiz dla całej strony</button>
|
||||||
|
<button class="action-btn ghost" @onclick="CloseAsync">Zamknij</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<NexusIcon Name="book-open" Size="14" Class="btn-icon" />
|
<div class="summary-box">
|
||||||
<span class="btn-text">Podsumuj</span>
|
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">Wykryto ciekawy fragment! Czy chcesz, abym wygenerował podsumowanie lub quiz z tego rozdziału?</NexusTypography>
|
||||||
|
</div>
|
||||||
|
<div class="ai-actions">
|
||||||
|
<button class="action-btn neon-border" @onclick="RequestSummary">Podsumuj zaznaczenie</button>
|
||||||
|
<button class="action-btn ghost" @onclick="CloseAsync">Pomiń</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</button>
|
</div>
|
||||||
<div class="toolbar-divider"></div>
|
<div class="bubble-pointer"></div>
|
||||||
<button id="quiz-btn" class="toolbar-btn secondary @(IsLoadingQuiz ? "loading" : "") @(IsAnyLoading ? "disabled cursor-not-allowed opacity-50" : "")"
|
</div>
|
||||||
disabled="@IsAnyLoading"
|
|
||||||
@onclick="GenerateQuizAsync">
|
|
||||||
@if (IsLoadingQuiz)
|
|
||||||
{
|
|
||||||
<svg class="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style="animation: spin 1s linear infinite; width: 14px; height: 14px; color: currentColor; display: inline-block; margin-right: 4px;">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" style="opacity: 0.25;"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" style="opacity: 0.75;"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="btn-text">Generowanie...</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<NexusIcon Name="target" Size="14" Class="btn-icon" />
|
|
||||||
<span class="btn-text">Quiz</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,145 +55,47 @@
|
|||||||
[Parameter] public string FullPageContent { get; set; } = string.Empty;
|
[Parameter] public string FullPageContent { get; set; } = string.Empty;
|
||||||
|
|
||||||
private bool IsVisible => !string.IsNullOrEmpty(SelectedText) && Coordinates != null;
|
private bool IsVisible => !string.IsNullOrEmpty(SelectedText) && Coordinates != null;
|
||||||
private bool IsLoadingSummary = false;
|
private bool IsLoading = false;
|
||||||
private bool IsLoadingQuiz = false;
|
private KnowledgePacket? Packet;
|
||||||
private bool IsAnyLoading => IsLoadingSummary || IsLoadingQuiz;
|
private bool PositionBelow => Coordinates != null && Coordinates.Top < 320;
|
||||||
|
|
||||||
private string _style = "visibility: hidden; opacity: 0; pointer-events: none;";
|
|
||||||
private bool _positionBelow = false;
|
|
||||||
private SelectionCoordinates? _lastCoordinates;
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
Logger.LogDebug("[SelectionAiPanel] Parameters set. SelectedText: {Length} chars, Coordinates: {Top}", SelectedText.Length, Coordinates?.Top);
|
Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}, PositionBelow: {PositionBelow}");
|
||||||
|
// Reset packet when selection changes
|
||||||
if (Coordinates != _lastCoordinates)
|
Packet = null;
|
||||||
{
|
|
||||||
_lastCoordinates = Coordinates;
|
|
||||||
_style = "visibility: hidden; opacity: 0; pointer-events: none;";
|
|
||||||
_positionBelow = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset loading states when parameters change
|
private string PanelStyle => Coordinates != null
|
||||||
IsLoadingSummary = false;
|
? string.Create(System.Globalization.CultureInfo.InvariantCulture,
|
||||||
IsLoadingQuiz = false;
|
$"top: {(PositionBelow ? Coordinates.Top + 35 : Coordinates.Top - 15):F1}px !important; " +
|
||||||
}
|
$"left: {Math.Clamp(Coordinates.Left + Coordinates.Width / 2, 280, 1600):F1}px !important; " +
|
||||||
|
$"transform: translate(-50%, {(PositionBelow ? "0" : "-100%")}) !important;")
|
||||||
|
: "";
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
private async Task RequestSummary()
|
||||||
{
|
|
||||||
if (IsVisible && _style.Contains("visibility: hidden"))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
|
|
||||||
var result = await module.InvokeAsync<PositionResult>("positionToolbar");
|
|
||||||
if (result != null)
|
|
||||||
{
|
|
||||||
_style = string.Create(System.Globalization.CultureInfo.InvariantCulture,
|
|
||||||
$"left: {result.Left:F1}px !important; " +
|
|
||||||
$"top: {result.Top:F1}px !important; " +
|
|
||||||
$"visibility: visible !important; " +
|
|
||||||
$"opacity: 1 !important; " +
|
|
||||||
$"pointer-events: auto !important;");
|
|
||||||
_positionBelow = result.Below;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogWarning(ex, "[SelectionAiPanel] Error positioning toolbar.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RequestSummaryAsync()
|
|
||||||
{
|
|
||||||
if (IsAnyLoading) return;
|
|
||||||
IsLoadingSummary = true;
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
|
|
||||||
var selectedText = await module.InvokeAsync<string>("getSelectionText");
|
|
||||||
if (string.IsNullOrWhiteSpace(selectedText))
|
|
||||||
{
|
|
||||||
selectedText = SelectedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(selectedText))
|
|
||||||
{
|
{
|
||||||
|
IsLoading = true;
|
||||||
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
|
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
|
||||||
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
_ = Coordinator.StartSelectionSummaryAsync($"{contextPrompt}{selectedText}");
|
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}");
|
||||||
|
Packet = result.IsSuccess ? result.Value : null;
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task GenerateFullQuiz()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
await Coordinator.RequestSummaryAndQuizAsync(FullPageContent);
|
||||||
|
IsLoading = false;
|
||||||
await CloseAsync();
|
await CloseAsync();
|
||||||
await InteractionService.RequestAssistant();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "[SelectionAiPanel] Error requesting summary for block {BlockId}.", BlockId);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsLoadingSummary = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task GenerateQuizAsync()
|
|
||||||
{
|
|
||||||
if (IsAnyLoading) return;
|
|
||||||
IsLoadingQuiz = true;
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
|
|
||||||
var selectedText = await module.InvokeAsync<string>("getSelectionText");
|
|
||||||
if (string.IsNullOrWhiteSpace(selectedText))
|
|
||||||
{
|
|
||||||
selectedText = SelectedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(selectedText))
|
|
||||||
{
|
|
||||||
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
|
|
||||||
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{selectedText}");
|
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
|
||||||
await CloseAsync();
|
|
||||||
await QuizService.RequestQuiz(BlockId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "[SelectionAiPanel] Error generating quiz for block {BlockId}.", BlockId);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsLoadingQuiz = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CloseAsync()
|
private async Task CloseAsync()
|
||||||
{
|
{
|
||||||
|
Packet = null;
|
||||||
await InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
|
await InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
|
||||||
}
|
}
|
||||||
|
|
||||||
private class PositionResult
|
|
||||||
{
|
|
||||||
public double Left { get; set; }
|
|
||||||
public double Top { get; set; }
|
|
||||||
public bool Below { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,149 +1,158 @@
|
|||||||
.selection-ai-panel {
|
.selection-ai-panel {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
z-index: 10000;
|
z-index: 9999;
|
||||||
display: flex;
|
width: 550px;
|
||||||
align-items: center;
|
max-width: 90vw;
|
||||||
background: rgba(24, 24, 28, 0.85);
|
animation: fadeInScale 0.2s ease-out;
|
||||||
backdrop-filter: blur(12px);
|
pointer-events: auto;
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.4);
|
|
||||||
padding: 4px 6px;
|
|
||||||
gap: 4px;
|
|
||||||
pointer-events: none; /* Controlled by inline styles */
|
|
||||||
user-select: none;
|
|
||||||
animation: fadeInScale 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeInScale {
|
@keyframes fadeInScale {
|
||||||
from {
|
from { opacity: 0; transform: translate(-50%, -90%) scale(0.95); }
|
||||||
opacity: 0;
|
to { opacity: 1; transform: translate(-50%, -100%) scale(1); }
|
||||||
transform: scale(0.96);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-ai-panel.below {
|
.ai-bubble {
|
||||||
animation: fadeInScaleBelow 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
position: relative;
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeInScaleBelow {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.96);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn {
|
|
||||||
display: flex;
|
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);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-avatar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 0.5rem;
|
||||||
padding: 6px 12px;
|
min-width: 100px;
|
||||||
background: transparent;
|
}
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
.avatar-label {
|
||||||
color: #e4e4e7; /* zinc-200 */
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-label .name {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-label .role {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-pulse {
|
||||||
|
color: #00ff99;
|
||||||
|
filter: drop-shadow(0 0 8px #00ff99);
|
||||||
|
animation: pulse 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { transform: scale(1); filter: drop-shadow(0 0 8px #00ff99); }
|
||||||
|
50% { transform: scale(1.05); filter: drop-shadow(0 0 15px #00ff99); }
|
||||||
|
100% { transform: scale(1); filter: drop-shadow(0 0 8px #00ff99); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-box {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #e0e0e0;
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-box::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-box::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 255, 153, 0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.5rem 1.2rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s ease;
|
||||||
white-space: nowrap;
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-btn:hover:not(.disabled) {
|
.action-btn.ghost {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: transparent;
|
||||||
color: #ffffff;
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-btn.primary {
|
.action-btn.neon-border {
|
||||||
color: var(--nexus-neon, #00ff99);
|
background: rgba(0, 255, 153, 0.1);
|
||||||
|
border: 1px solid #00ff99;
|
||||||
|
color: #00ff99;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-btn.primary:hover:not(.disabled) {
|
.action-btn:hover {
|
||||||
background: rgba(0, 255, 153, 0.08);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 0 12px rgba(0, 255, 153, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 255, 153, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-btn.disabled {
|
.bubble-pointer {
|
||||||
opacity: 0.35;
|
position: absolute;
|
||||||
cursor: not-allowed;
|
left: 50%;
|
||||||
pointer-events: none;
|
transform: translateX(-50%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 10px solid transparent;
|
||||||
|
border-right: 10px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-divider {
|
.selection-ai-panel:not(.below) .bubble-pointer {
|
||||||
width: 1px;
|
bottom: -10px;
|
||||||
height: 16px;
|
border-top: 10px solid rgba(18, 18, 18, 0.95);
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.selection-ai-panel.below .bubble-pointer {
|
||||||
display: inline-flex;
|
top: -10px;
|
||||||
align-items: center;
|
border-bottom: 10px solid rgba(18, 18, 18, 0.95);
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner-inline {
|
.loading-state {
|
||||||
width: 12px;
|
padding: 1rem;
|
||||||
height: 12px;
|
|
||||||
border: 2px solid currentColor;
|
|
||||||
border-top-color: transparent;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
display: inline-block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
.shimmer {
|
||||||
to {
|
background: linear-gradient(90deg, transparent, rgba(0, 255, 153, 0.2), transparent);
|
||||||
transform: rotate(360deg);
|
background-size: 200% 100%;
|
||||||
}
|
animation: shimmer 1.5s infinite;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opacity-50 {
|
@keyframes shimmer {
|
||||||
opacity: 0.5 !important;
|
from { background-position: 200% 0; }
|
||||||
|
to { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.cursor-not-allowed {
|
|
||||||
cursor: not-allowed !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light mode overrides */
|
|
||||||
.theme-light .selection-ai-panel {
|
|
||||||
background: rgba(254, 254, 254, 0.95);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .toolbar-btn {
|
|
||||||
color: #57524e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .toolbar-btn:hover:not(.disabled) {
|
|
||||||
background: rgba(0, 0, 0, 0.04);
|
|
||||||
color: #1c1917;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .toolbar-btn.primary {
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .toolbar-btn.primary:hover:not(.disabled) {
|
|
||||||
background: rgba(16, 185, 129, 0.06);
|
|
||||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .toolbar-divider {
|
|
||||||
background: rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,21 +27,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="parsing-state shimmer" style="@(IsParsing ? "display:flex;" : "display:none;")">
|
<div class="parsing-state shimmer" style="@(IsParsing && !IsIndexing ? "display:flex;" : "display:none;")">
|
||||||
<div class="shimmer-content">
|
<div class="shimmer-content">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Scanning metadata...</p>
|
<p>Scanning metadata...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ingesting-state shimmer" style="@(IsIngesting ? "display:flex;" : "display:none;")">
|
<div class="verification-state" style="@(IsVerifying && !IsParsing && !IsIndexing ? "display:flex;" : "display:none;")">
|
||||||
<div class="shimmer-content">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>Saving book to library...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="verification-state" style="@((IsVerifying && !IsIngesting) ? "display:flex;" : "display:none;")">
|
|
||||||
@if (Metadata != null)
|
@if (Metadata != null)
|
||||||
{
|
{
|
||||||
<div class="verification-layout">
|
<div class="verification-layout">
|
||||||
@@ -86,7 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="upload-state @(_isDragging ? "drag-over" : "")"
|
<div class="upload-state @(_isDragging ? "drag-over" : "")"
|
||||||
style="@(IsUploadActive ? "display:flex;" : "display:none;")"
|
style="@(!IsParsing && !IsVerifying && !IsIndexing ? "display:flex;" : "display:none;")"
|
||||||
@ondragenter="OnDragEnter"
|
@ondragenter="OnDragEnter"
|
||||||
@ondragleave="OnDragLeave">
|
@ondragleave="OnDragLeave">
|
||||||
<div class="drop-zone">
|
<div class="drop-zone">
|
||||||
@@ -149,8 +142,6 @@
|
|||||||
private LocalEpubMetadata? Metadata { get; set; }
|
private LocalEpubMetadata? Metadata { get; set; }
|
||||||
private string? ErrorMessage { get; set; }
|
private string? ErrorMessage { get; set; }
|
||||||
private byte[]? _epubBytes;
|
private byte[]? _epubBytes;
|
||||||
private bool _disposed;
|
|
||||||
private bool IsUploadActive => !IsParsing && !IsVerifying && !IsIngesting && !IsIndexing;
|
|
||||||
|
|
||||||
// Allow up to 50 MB
|
// Allow up to 50 MB
|
||||||
private const long MaxFileSize = 50 * 1024 * 1024;
|
private const long MaxFileSize = 50 * 1024 * 1024;
|
||||||
@@ -163,34 +154,23 @@
|
|||||||
|
|
||||||
private async Task HandleIngestionProgress(string message, double progress)
|
private async Task HandleIngestionProgress(string message, double progress)
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
|
||||||
if (!IsIndexing) return;
|
if (!IsIndexing) return;
|
||||||
|
|
||||||
IngestionStatusMessage = message;
|
IngestionStatusMessage = message;
|
||||||
IngestionProgressPercent = progress;
|
IngestionProgressPercent = progress;
|
||||||
|
|
||||||
if (!_disposed)
|
|
||||||
{
|
|
||||||
// Dispatch the state change to the Blazor synchronization context
|
|
||||||
// because this event is triggered asynchronously from a SignalR / WebSocket background thread.
|
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
|
||||||
|
|
||||||
if (progress >= 1.0)
|
if (progress >= 1.0)
|
||||||
{
|
{
|
||||||
// Give the user a moment to see the completion message
|
// Give the user a moment to see the completion message
|
||||||
await Task.Delay(2500);
|
await Task.Delay(2500);
|
||||||
|
|
||||||
if (_disposed) return;
|
|
||||||
|
|
||||||
// Now close the modal and navigate to the book
|
// Now close the modal and navigate to the book
|
||||||
if (IngestedBookId != Guid.Empty)
|
if (IngestedBookId != Guid.Empty)
|
||||||
{
|
{
|
||||||
var bookId = IngestedBookId;
|
var bookId = IngestedBookId;
|
||||||
// Dispatch UI updates and navigation back to the Blazor thread
|
|
||||||
// to avoid thread affinity issues and potential UI lockups in MAUI/Web applications.
|
|
||||||
await InvokeAsync(async () => {
|
await InvokeAsync(async () => {
|
||||||
if (_disposed) return;
|
|
||||||
await CloseModal();
|
await CloseModal();
|
||||||
ReaderNavigation.NavigateToBook(bookId);
|
ReaderNavigation.NavigateToBook(bookId);
|
||||||
});
|
});
|
||||||
@@ -247,12 +227,10 @@
|
|||||||
using var stream = file.OpenReadStream(MaxFileSize);
|
using var stream = file.OpenReadStream(MaxFileSize);
|
||||||
using var memoryStream = new MemoryStream();
|
using var memoryStream = new MemoryStream();
|
||||||
await stream.CopyToAsync(memoryStream);
|
await stream.CopyToAsync(memoryStream);
|
||||||
if (_disposed) return;
|
|
||||||
_epubBytes = memoryStream.ToArray();
|
_epubBytes = memoryStream.ToArray();
|
||||||
|
|
||||||
memoryStream.Position = 0;
|
memoryStream.Position = 0;
|
||||||
var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream);
|
var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream);
|
||||||
if (_disposed) return;
|
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
@@ -267,20 +245,14 @@
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "Error uploading EPUB");
|
Logger.LogError(ex, "Error uploading EPUB");
|
||||||
if (!_disposed)
|
|
||||||
{
|
|
||||||
ErrorMessage = $"An unexpected error occurred: {ex.Message}";
|
ErrorMessage = $"An unexpected error occurred: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
|
||||||
finally
|
finally
|
||||||
{
|
|
||||||
if (!_disposed)
|
|
||||||
{
|
{
|
||||||
IsParsing = false;
|
IsParsing = false;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveToLibrary()
|
private async Task SaveToLibrary()
|
||||||
{
|
{
|
||||||
@@ -301,12 +273,10 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
var response = await Http.PostAsJsonAsync("api/library/ingest", request);
|
var response = await Http.PostAsJsonAsync("api/library/ingest", request);
|
||||||
if (_disposed) return;
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var result = await response.Content.ReadFromJsonAsync<IngestResult>();
|
var result = await response.Content.ReadFromJsonAsync<IngestResult>();
|
||||||
if (_disposed) return;
|
|
||||||
if (result != null)
|
if (result != null)
|
||||||
{
|
{
|
||||||
IngestedBookId = result.Id;
|
IngestedBookId = result.Id;
|
||||||
@@ -327,26 +297,19 @@
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "Error during ingestion");
|
Logger.LogError(ex, "Error during ingestion");
|
||||||
if (!_disposed)
|
|
||||||
{
|
|
||||||
ErrorMessage = "Failed to save book to library. Please try again.";
|
ErrorMessage = "Failed to save book to library. Please try again.";
|
||||||
IsIngesting = false;
|
IsIngesting = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
finally
|
finally
|
||||||
{
|
|
||||||
if (!_disposed)
|
|
||||||
{
|
{
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private record IngestResult(Guid Id);
|
private record IngestResult(Guid Id);
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
_disposed = true;
|
|
||||||
SyncService.OnIngestionProgressReceived -= HandleIngestionProgress;
|
SyncService.OnIngestionProgressReceived -= HandleIngestionProgress;
|
||||||
// Clear the large byte array so it is eligible for GC even if the component is cached.
|
// Clear the large byte array so it is eligible for GC even if the component is cached.
|
||||||
_epubBytes = null;
|
_epubBytes = null;
|
||||||
|
|||||||
@@ -118,9 +118,8 @@
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Parsing and Ingesting States */
|
/* Parsing State */
|
||||||
.parsing-state,
|
.parsing-state {
|
||||||
.ingesting-state {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -159,8 +158,7 @@
|
|||||||
filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.3));
|
filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.parsing-state p,
|
.parsing-state p {
|
||||||
.ingesting-state p {
|
|
||||||
color: var(--nexus-text);
|
color: var(--nexus-text);
|
||||||
font-family: var(--nexus-font-mono, monospace);
|
font-family: var(--nexus-font-mono, monospace);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -196,37 +194,52 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .nexus-btn.btn-primary {
|
.btn {
|
||||||
background: var(--nexus-neon, #00ffaa) !important;
|
font-family: var(--nexus-font-sans);
|
||||||
color: #050505 !important;
|
font-weight: 600;
|
||||||
border-color: transparent !important;
|
padding: 0.75rem 1.5rem;
|
||||||
box-shadow: 0 4px 12px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.2) !important;
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .nexus-btn.btn-primary:hover:not(:disabled) {
|
.btn-primary {
|
||||||
background: #00e699 !important;
|
background: var(--nexus-neon, #00ffaa);
|
||||||
transform: translateY(-2px) !important;
|
color: #050505;
|
||||||
box-shadow: 0 6px 20px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.4) !important;
|
box-shadow: 0 4px 12px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .nexus-btn.btn-primary:active:not(:disabled) {
|
.btn-primary:hover {
|
||||||
transform: translateY(0) !important;
|
background: #00e699;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .nexus-btn.btn-secondary {
|
.btn-primary:active {
|
||||||
background: rgba(255, 255, 255, 0.03) !important;
|
transform: translateY(0);
|
||||||
color: var(--nexus-text) !important;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .nexus-btn.btn-secondary:hover:not(:disabled) {
|
.btn-secondary {
|
||||||
background: rgba(255, 255, 255, 0.08) !important;
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border-color: rgba(255, 255, 255, 0.3) !important;
|
color: var(--nexus-text);
|
||||||
transform: translateY(-2px) !important;
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .nexus-btn.btn-secondary:active:not(:disabled) {
|
.btn-secondary:hover {
|
||||||
transform: translateY(0) !important;
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:active {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Verification State */
|
/* Verification State */
|
||||||
@@ -342,30 +355,26 @@
|
|||||||
to { transform: scale(1.2); opacity: 0.8; }
|
to { transform: scale(1.2); opacity: 0.8; }
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .nexus-btn:disabled:not(.btn-loading) {
|
.btn:disabled {
|
||||||
opacity: 0.4 !important;
|
opacity: 0.5;
|
||||||
cursor: not-allowed !important;
|
cursor: not-allowed;
|
||||||
filter: grayscale(1) !important;
|
filter: grayscale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .nexus-btn.btn-loading {
|
.btn-loading {
|
||||||
position: relative !important;
|
position: relative;
|
||||||
color: transparent !important;
|
color: transparent !important;
|
||||||
opacity: 1 !important;
|
|
||||||
cursor: wait !important;
|
|
||||||
filter: none !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .nexus-btn.btn-loading::after {
|
.btn-loading::after {
|
||||||
content: "" !important;
|
content: "";
|
||||||
position: absolute !important;
|
position: absolute;
|
||||||
width: 20px !important;
|
width: 20px;
|
||||||
height: 20px !important;
|
height: 20px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.2) !important;
|
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||||
border-top-color: var(--nexus-neon, #00ffaa) !important;
|
border-top-color: #000;
|
||||||
border-radius: 50% !important;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite !important;
|
animation: spin 0.8s linear infinite;
|
||||||
filter: drop-shadow(0 0 4px var(--nexus-neon, #00ffaa)) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Indexing State */
|
/* Indexing State */
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
@using NexusReader.Application.Queries.Graph
|
|
||||||
@using NexusReader.Application.Utilities
|
|
||||||
@using NexusReader.UI.Shared.Components.Atoms
|
|
||||||
|
|
||||||
<div class="concepts-map">
|
|
||||||
@if (Nodes == null || !Nodes.Any())
|
|
||||||
{
|
|
||||||
<div class="empty-map-state">
|
|
||||||
<NexusIcon Name="map" Size="48" Class="dim-icon" />
|
|
||||||
<p>Brak wygenerowanej mapy pojęć dla tej książki.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="timeline-container">
|
|
||||||
@for (int i = 0; i < Nodes.Count; i++)
|
|
||||||
{
|
|
||||||
var index = i;
|
|
||||||
var node = Nodes[index];
|
|
||||||
var isUnlocked = IsUnlocked(node.Id);
|
|
||||||
var isSelected = SelectedNode?.Id == node.Id;
|
|
||||||
|
|
||||||
var showTrack = index < Nodes.Count - 1;
|
|
||||||
var isNextUnlocked = showTrack && IsUnlocked(Nodes[index + 1].Id);
|
|
||||||
|
|
||||||
<div class="timeline-step @(isUnlocked ? "unlocked" : "locked") @(isSelected ? "selected" : "")"
|
|
||||||
@onclick="() => HandleNodeClick(node)">
|
|
||||||
|
|
||||||
<div class="node-connector-wrapper">
|
|
||||||
<div class="node-circle">
|
|
||||||
@if (isUnlocked)
|
|
||||||
{
|
|
||||||
<div class="node-glow"></div>
|
|
||||||
<NexusIcon Name="check" Size="16" Class="check-icon" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<NexusIcon Name="lock" Size="16" Class="lock-icon" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (showTrack)
|
|
||||||
{
|
|
||||||
<div class="vertical-track @(isNextUnlocked ? "track-active" : "track-inactive")"></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="node-content">
|
|
||||||
<div class="node-header">
|
|
||||||
<span class="segment-tag">@node.Id.ToUpper()</span>
|
|
||||||
@if (isUnlocked)
|
|
||||||
{
|
|
||||||
<span class="badge badge-unlocked">Odblokowane</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="badge badge-locked">Zablokowane</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<h4 class="node-title">@node.Label</h4>
|
|
||||||
<p class="node-desc">@GetShortDescription(node.Description)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public List<GraphNodeDto> Nodes { get; set; } = new();
|
|
||||||
[Parameter] public string LastReadBlockId { get; set; } = string.Empty;
|
|
||||||
[Parameter] public EventCallback<GraphNodeDto> OnNodeSelected { get; set; }
|
|
||||||
[Parameter] public GraphNodeDto? SelectedNode { get; set; }
|
|
||||||
|
|
||||||
private bool IsUnlocked(string nodeId)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(nodeId)) return false;
|
|
||||||
|
|
||||||
var nodeSeq = SegmentIdParser.Parse(nodeId);
|
|
||||||
|
|
||||||
// Always unlock the very first segment so the user has a starting node
|
|
||||||
var minNodeSeq = Nodes.Any() ? Nodes.Min(n => SegmentIdParser.Parse(n.Id)) : 0;
|
|
||||||
if (nodeSeq == minNodeSeq) return true;
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(LastReadBlockId))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var progressSeq = SegmentIdParser.Parse(LastReadBlockId);
|
|
||||||
return nodeSeq <= progressSeq;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private async Task HandleNodeClick(GraphNodeDto node)
|
|
||||||
{
|
|
||||||
if (OnNodeSelected.HasDelegate)
|
|
||||||
{
|
|
||||||
await OnNodeSelected.InvokeAsync(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetShortDescription(string? desc)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(desc)) return "Brak opisu.";
|
|
||||||
if (desc.Length <= 110) return desc;
|
|
||||||
return desc[..107] + "...";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
.concepts-map {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 72vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar Customization for modern aesthetic */
|
|
||||||
.concepts-map::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
.concepts-map::-webkit-scrollbar-track {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
|
||||||
.concepts-map::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.concepts-map::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--nexus-neon);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-map-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 3rem;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
border: 1px dashed rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-map-state .dim-icon {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
position: relative;
|
|
||||||
padding-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-step {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-step:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
transform: translateX(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-step.unlocked:hover {
|
|
||||||
border-color: rgba(0, 255, 153, 0.15);
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 255, 153, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-step.selected {
|
|
||||||
background: rgba(0, 255, 153, 0.04);
|
|
||||||
border-color: var(--nexus-neon);
|
|
||||||
box-shadow: 0 0 15px var(--nexus-primary-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-connector-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
width: 32px;
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-circle {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
background: #0d0d0d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unlocked .node-circle {
|
|
||||||
border: 2px solid var(--nexus-neon);
|
|
||||||
color: var(--nexus-neon);
|
|
||||||
box-shadow: 0 0 10px var(--nexus-primary-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.locked .node-circle {
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
|
||||||
color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-glow {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nexus-neon);
|
|
||||||
opacity: 0.15;
|
|
||||||
filter: blur(4px);
|
|
||||||
z-index: -1;
|
|
||||||
animation: pulse-glow 2s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-glow {
|
|
||||||
0% { transform: scale(1); opacity: 0.15; }
|
|
||||||
50% { transform: scale(1.25); opacity: 0.3; }
|
|
||||||
100% { transform: scale(1); opacity: 0.15; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-track {
|
|
||||||
width: 2px;
|
|
||||||
position: absolute;
|
|
||||||
top: 32px;
|
|
||||||
bottom: -18px; /* Extends to link to next node circle */
|
|
||||||
z-index: 1;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-active {
|
|
||||||
background: linear-gradient(180deg, var(--nexus-neon), rgba(0, 255, 153, 0.2));
|
|
||||||
box-shadow: 0 0 6px var(--nexus-primary-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-inactive {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-content {
|
|
||||||
flex-grow: 1;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-step.selected .node-content {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border-color: rgba(0, 255, 153, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-tag {
|
|
||||||
font-family: 'Outfit', sans-serif;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.unlocked .segment-tag {
|
|
||||||
color: var(--nexus-neon);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-unlocked {
|
|
||||||
background: rgba(0, 255, 153, 0.08);
|
|
||||||
color: var(--nexus-neon);
|
|
||||||
border: 1px solid rgba(0, 255, 153, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-locked {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-step.unlocked:hover .node-title {
|
|
||||||
color: var(--nexus-neon);
|
|
||||||
}
|
|
||||||
|
|
||||||
.locked .node-title {
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-desc {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.locked .node-desc {
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-icon {
|
|
||||||
color: var(--nexus-neon);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-icon {
|
|
||||||
color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
LIGHT THEME OVERRIDES — "Warm Paper / Soft Sepia"
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
.theme-light .concepts-map::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .empty-map-state {
|
|
||||||
background: rgba(0, 0, 0, 0.01);
|
|
||||||
border-color: var(--border);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .empty-map-state .dim-icon {
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .timeline-step:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .timeline-step.unlocked:hover {
|
|
||||||
border-color: rgba(16, 185, 129, 0.15);
|
|
||||||
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .timeline-step.selected {
|
|
||||||
background: rgba(16, 185, 129, 0.04);
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .node-circle {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .unlocked .node-circle {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .locked .node-circle {
|
|
||||||
background: var(--bg-base);
|
|
||||||
border-color: var(--border);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .node-glow {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .track-active {
|
|
||||||
background: var(--accent);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .track-inactive {
|
|
||||||
background: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .node-content {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .timeline-step.selected .node-content {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-color: rgba(16, 185, 129, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .segment-tag {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .unlocked .segment-tag {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .badge-unlocked {
|
|
||||||
background: rgba(16, 185, 129, 0.08);
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: rgba(16, 185, 129, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .badge-locked {
|
|
||||||
background: var(--bg-base);
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .node-title {
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .timeline-step.unlocked:hover .node-title {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .locked .node-title {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .node-desc {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .locked .node-desc {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .check-icon {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .lock-icon {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user