Compare commits

..

17 Commits

Author SHA1 Message Date
e7435435c1 Merge pull request 'feature/FA-misc_ServersidedChapterReader' (#61) from feature/FA-misc_ServersidedChapterReader into master
All checks were successful
CI / build-backend (push) Successful in 1m5s
CI / build-frontend (push) Successful in 43s
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (push) Successful in 50s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (push) Successful in 43s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (push) Successful in 45s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (push) Successful in 43s
Build Gateway / build-subgraphs (map[name:usernoveldata-service project:FictionArchive.Service.UserNovelDataService subgraph:UserNovelData]) (push) Successful in 45s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (push) Successful in 1m59s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (push) Successful in 1m53s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (push) Successful in 1m47s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (push) Successful in 1m41s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (push) Successful in 1m43s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserNovelDataService/Dockerfile name:usernoveldata-service]) (push) Successful in 1m41s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (push) Successful in 1m34s
Release / build-frontend (push) Successful in 1m43s
Build Gateway / build-gateway (push) Successful in 3m10s
Reviewed-on: #61
2026-01-26 16:32:52 +00:00
gamer147
dd7aa4b044 [FA-misc] Resolve lint issues
All checks were successful
CI / build-backend (pull_request) Successful in 1m45s
CI / build-frontend (pull_request) Successful in 45s
2026-01-26 11:29:51 -05:00
gamer147
1b9da7441c [FA-misc] Chapter reader is now serverside loaded with a clientside fallback, fixed a warning related to reading lists 2026-01-26 11:27:05 -05:00
055ef33666 Merge pull request '[FA-24] Reading lists' (#60) from feature/FA-24_ReadingLists into master
All checks were successful
CI / build-backend (push) Successful in 1m4s
CI / build-frontend (push) Successful in 41s
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (push) Successful in 49s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (push) Successful in 45s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (push) Successful in 46s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (push) Successful in 43s
Build Gateway / build-subgraphs (map[name:usernoveldata-service project:FictionArchive.Service.UserNovelDataService subgraph:UserNovelData]) (push) Successful in 44s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (push) Successful in 2m3s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (push) Successful in 1m53s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (push) Successful in 1m43s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (push) Successful in 1m39s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (push) Successful in 1m42s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserNovelDataService/Dockerfile name:usernoveldata-service]) (push) Successful in 1m38s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (push) Successful in 1m32s
Release / build-frontend (push) Successful in 1m38s
Build Gateway / build-gateway (push) Successful in 3m18s
Reviewed-on: #60
2026-01-20 03:09:45 +00:00
gamer147
48ee43c4f6 [FA-24] Reading lists
All checks were successful
CI / build-backend (pull_request) Successful in 1m32s
CI / build-frontend (pull_request) Successful in 42s
2026-01-19 22:06:34 -05:00
98ae4ea4f2 Merge pull request 'feature/FA-27_Bookmarks' (#59) from feature/FA-27_Bookmarks into master
All checks were successful
CI / build-backend (push) Successful in 1m16s
CI / build-frontend (push) Successful in 40s
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (push) Successful in 47s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (push) Successful in 42s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (push) Successful in 45s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (push) Successful in 43s
Build Gateway / build-subgraphs (map[name:usernoveldata-service project:FictionArchive.Service.UserNovelDataService subgraph:UserNovelData]) (push) Successful in 43s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (push) Successful in 2m19s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (push) Successful in 2m3s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (push) Successful in 1m41s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (push) Successful in 1m37s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (push) Successful in 1m48s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserNovelDataService/Dockerfile name:usernoveldata-service]) (push) Successful in 1m34s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (push) Successful in 1m33s
Release / build-frontend (push) Successful in 1m39s
Build Gateway / build-gateway (push) Successful in 3m11s
Reviewed-on: #59
2026-01-19 22:28:03 +00:00
gamer147
15e1a84f55 [FA-27] Update CICD
All checks were successful
CI / build-backend (pull_request) Successful in 1m6s
CI / build-frontend (pull_request) Successful in 41s
2026-01-19 17:03:44 -05:00
gamer147
70d4ba201a [FA-27] Fix unit test based on changes
All checks were successful
CI / build-backend (pull_request) Successful in 1m10s
CI / build-frontend (pull_request) Successful in 43s
2026-01-19 16:47:55 -05:00
gamer147
b69bcd6bf4 [FA-27] Fix user adding not using correct id
Some checks failed
CI / build-backend (pull_request) Failing after 1m2s
CI / build-frontend (pull_request) Successful in 41s
2026-01-19 16:14:49 -05:00
gamer147
c97654631b [FA-27] Still need to test events 2026-01-19 15:40:21 -05:00
gamer147
1ecfd9cc99 [FA-27] Need to test events but seems to mostly work 2026-01-19 15:13:14 -05:00
gamer147
19ae4a8089 Add .worktrees/ to .gitignore 2026-01-19 01:36:10 -05:00
gamer147
f8a45ad891 [FA-27] Bookmark implementation 2026-01-19 00:01:16 -05:00
gamer147
f67c5c610c Merge branch 'refs/heads/master' into feature/FA-27_Bookmarks 2025-12-30 11:07:36 -05:00
b5d4694f12 Merge pull request '[FA-misc] Update docker-compose.yml' (#58) from feature/FA-misc_AddDockerComposeUserService into master
All checks were successful
CI / build-backend (push) Successful in 1m7s
CI / build-frontend (push) Successful in 40s
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (push) Successful in 55s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (push) Successful in 49s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (push) Successful in 50s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (push) Successful in 48s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (push) Successful in 2m25s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (push) Successful in 2m28s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (push) Successful in 2m14s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (push) Successful in 2m8s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (push) Successful in 2m15s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (push) Successful in 1m43s
Release / build-frontend (push) Successful in 1m43s
Build Gateway / build-gateway (push) Successful in 4m1s
Reviewed-on: #58
2025-12-30 03:26:06 +00:00
gamer147
6d47153a42 [FA-misc] Update docker-compose.yml
All checks were successful
CI / build-backend (pull_request) Successful in 1m26s
CI / build-frontend (pull_request) Successful in 50s
2025-12-29 22:23:29 -05:00
gamer147
5013da69c2 [FA-27] UserNovelDataService bootstrapped, going to do author's posts first i think 2025-12-29 14:54:01 -05:00
91 changed files with 5898 additions and 166 deletions

View File

@@ -28,6 +28,9 @@ jobs:
- name: user-service
project: FictionArchive.Service.UserService
subgraph: User
- name: usernoveldata-service
project: FictionArchive.Service.UserNovelDataService
subgraph: UserNovelData
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -110,6 +113,12 @@ jobs:
name: user-service-subgraph
path: subgraphs/user
- name: Download UserNovelData Service subgraph
uses: christopherhx/gitea-download-artifact@v4
with:
name: usernoveldata-service-subgraph
path: subgraphs/usernoveldata
- name: Configure subgraph URLs for Docker
run: |
for fsp in subgraphs/*/*.fsp; do

View File

@@ -27,6 +27,8 @@ jobs:
dockerfile: FictionArchive.Service.SchedulerService/Dockerfile
- name: authentication-service
dockerfile: FictionArchive.Service.AuthenticationService/Dockerfile
- name: usernoveldata-service
dockerfile: FictionArchive.Service.UserNovelDataService/Dockerfile
steps:
- name: Checkout
uses: actions/checkout@v4

5
.gitignore vendored
View File

@@ -139,4 +139,7 @@ appsettings.Local.json
# Fusion Builds
schema.graphql
*.fsp
gateway.fgp
gateway.fgp
# Git worktrees
.worktrees/

View File

@@ -0,0 +1,13 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class ChapterCreatedEvent : IIntegrationEvent
{
public required uint ChapterId { get; init; }
public required uint NovelId { get; init; }
public required uint VolumeId { get; init; }
public required int VolumeOrder { get; init; }
public required uint ChapterOrder { get; init; }
public required string ChapterTitle { get; init; }
}

View File

@@ -0,0 +1,13 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class NovelCreatedEvent : IIntegrationEvent
{
public required uint NovelId { get; init; }
public required string Title { get; init; }
public required Language OriginalLanguage { get; init; }
public required string Source { get; init; }
public required string AuthorName { get; init; }
}

View File

@@ -343,6 +343,12 @@ public class NovelUpdateService
Novel novel;
bool shouldPublishCoverEvent;
// Capture existing chapter IDs to detect new chapters later
var existingChapterIds = existingNovel?.Volumes
.SelectMany(v => v.Chapters)
.Select(c => c.Id)
.ToHashSet() ?? new HashSet<uint>();
if (existingNovel == null)
{
// CREATE PATH: New novel
@@ -384,6 +390,36 @@ public class NovelUpdateService
await _dbContext.SaveChangesAsync();
// Publish novel created event for new novels
if (existingNovel == null)
{
await _eventBus.Publish(new NovelCreatedEvent
{
NovelId = novel.Id,
Title = novel.Name.Texts.First(t => t.Language == novel.RawLanguage).Text,
OriginalLanguage = novel.RawLanguage,
Source = novel.Source.Key,
AuthorName = novel.Author.Name.Texts.First(t => t.Language == novel.RawLanguage).Text
});
}
// Publish chapter created events for new chapters
foreach (var volume in novel.Volumes)
{
foreach (var chapter in volume.Chapters.Where(c => !existingChapterIds.Contains(c.Id)))
{
await _eventBus.Publish(new ChapterCreatedEvent
{
ChapterId = chapter.Id,
NovelId = novel.Id,
VolumeId = volume.Id,
VolumeOrder = volume.Order,
ChapterOrder = chapter.Order,
ChapterTitle = chapter.Name.Texts.First(t => t.Language == novel.RawLanguage).Text
});
}
}
// Publish cover image event if needed
if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null)
{

View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj", "FictionArchive.Service.UserNovelDataService/"]
RUN dotnet restore "FictionArchive.Service.UserNovelDataService/FictionArchive.Service.UserNovelDataService.csproj"
COPY . .
WORKDIR "/src/FictionArchive.Service.UserNovelDataService"
RUN dotnet build "./FictionArchive.Service.UserNovelDataService.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.Service.UserNovelDataService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FictionArchive.Service.UserNovelDataService.dll"]

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,498 @@
using System.Security.Claims;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.DTOs;
using FictionArchive.Service.UserNovelDataService.Services;
using HotChocolate.Authorization;
using HotChocolate.Types;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.GraphQL;
public class Mutation
{
[Authorize]
[Error<InvalidOperationException>]
public async Task<BookmarkPayload> UpsertBookmark(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
UpsertBookmarkInput input)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
// Auto-create user if not exists
user = new User { OAuthProviderId = oAuthProviderId };
dbContext.Users.Add(user);
await dbContext.SaveChangesAsync();
}
var existingBookmark = await dbContext.Bookmarks
.FirstOrDefaultAsync(b => b.UserId == user.Id && b.ChapterId == input.ChapterId);
if (existingBookmark != null)
{
// Update existing
existingBookmark.Description = input.Description;
}
else
{
// Create new
existingBookmark = new Bookmark
{
UserId = user.Id,
NovelId = input.NovelId,
ChapterId = input.ChapterId,
Description = input.Description
};
dbContext.Bookmarks.Add(existingBookmark);
}
await dbContext.SaveChangesAsync();
return new BookmarkPayload
{
Success = true,
Bookmark = new BookmarkDto
{
Id = existingBookmark.Id,
ChapterId = existingBookmark.ChapterId,
NovelId = existingBookmark.NovelId,
Description = existingBookmark.Description,
CreatedTime = existingBookmark.CreatedTime
}
};
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<BookmarkPayload> RemoveBookmark(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
uint chapterId)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new BookmarkPayload { Success = false };
}
var bookmark = await dbContext.Bookmarks
.FirstOrDefaultAsync(b => b.UserId == user.Id && b.ChapterId == chapterId);
if (bookmark == null)
{
return new BookmarkPayload { Success = false };
}
dbContext.Bookmarks.Remove(bookmark);
await dbContext.SaveChangesAsync();
return new BookmarkPayload { Success = true };
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<ReadingListPayload> CreateReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
CreateReadingListInput input)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
if (string.IsNullOrWhiteSpace(input.Name))
{
throw new InvalidOperationException("Reading list name is required");
}
var user = await dbContext.Users
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
user = new User { OAuthProviderId = oAuthProviderId };
dbContext.Users.Add(user);
await dbContext.SaveChangesAsync();
}
var readingList = new ReadingList
{
UserId = user.Id,
Name = input.Name.Trim(),
Description = input.Description?.Trim()
};
dbContext.ReadingLists.Add(readingList);
await dbContext.SaveChangesAsync();
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = [],
ItemCount = 0,
CreatedTime = readingList.CreatedTime
}
};
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<ReadingListPayload> UpdateReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
UpdateReadingListInput input)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
if (string.IsNullOrWhiteSpace(input.Name))
{
throw new InvalidOperationException("Reading list name is required");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new ReadingListPayload { Success = false };
}
var readingList = await dbContext.ReadingLists
.Include(r => r.Items)
.FirstOrDefaultAsync(r => r.Id == input.Id && r.UserId == user.Id);
if (readingList == null)
{
return new ReadingListPayload { Success = false };
}
readingList.Name = input.Name.Trim();
readingList.Description = input.Description?.Trim();
await dbContext.SaveChangesAsync();
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
ItemCount = readingList.Items.Count,
CreatedTime = readingList.CreatedTime
}
};
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<DeleteReadingListPayload> DeleteReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
int id)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new DeleteReadingListPayload { Success = false };
}
var readingList = await dbContext.ReadingLists
.FirstOrDefaultAsync(r => r.Id == id && r.UserId == user.Id);
if (readingList == null)
{
return new DeleteReadingListPayload { Success = false };
}
dbContext.ReadingLists.Remove(readingList);
await dbContext.SaveChangesAsync();
return new DeleteReadingListPayload { Success = true };
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<ReadingListPayload> AddToReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
AddToReadingListInput input)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new ReadingListPayload { Success = false };
}
var readingList = await dbContext.ReadingLists
.Include(r => r.Items)
.FirstOrDefaultAsync(r => r.Id == input.ReadingListId && r.UserId == user.Id);
if (readingList == null)
{
return new ReadingListPayload { Success = false };
}
// Idempotent: if already in list, return success
var existingItem = readingList.Items.FirstOrDefault(i => i.NovelId == input.NovelId);
if (existingItem != null)
{
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
ItemCount = readingList.Items.Count,
CreatedTime = readingList.CreatedTime
}
};
}
// Add at the end (highest order + 1)
var maxOrder = readingList.Items.Any() ? readingList.Items.Max(i => i.Order) : -1;
var newItem = new ReadingListItem
{
ReadingListId = readingList.Id,
NovelId = input.NovelId,
Order = maxOrder + 1
};
dbContext.ReadingListItems.Add(newItem);
await dbContext.SaveChangesAsync();
// Reload to get updated items
readingList = await dbContext.ReadingLists
.AsNoTracking()
.Include(r => r.Items.OrderBy(i => i.Order))
.FirstAsync(r => r.Id == input.ReadingListId);
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = readingList.Items.Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
ItemCount = readingList.Items.Count,
CreatedTime = readingList.CreatedTime
}
};
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<ReadingListPayload> RemoveFromReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
int listId,
uint novelId)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new ReadingListPayload { Success = false };
}
var readingList = await dbContext.ReadingLists
.Include(r => r.Items)
.FirstOrDefaultAsync(r => r.Id == listId && r.UserId == user.Id);
if (readingList == null)
{
return new ReadingListPayload { Success = false };
}
var item = readingList.Items.FirstOrDefault(i => i.NovelId == novelId);
if (item != null)
{
dbContext.ReadingListItems.Remove(item);
await dbContext.SaveChangesAsync();
}
// Reload to get updated items
readingList = await dbContext.ReadingLists
.AsNoTracking()
.Include(r => r.Items.OrderBy(i => i.Order))
.FirstAsync(r => r.Id == listId);
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = readingList.Items.Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
ItemCount = readingList.Items.Count,
CreatedTime = readingList.CreatedTime
}
};
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<ReadingListPayload> ReorderReadingListItem(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
ReorderReadingListItemInput input)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new ReadingListPayload { Success = false };
}
var readingList = await dbContext.ReadingLists
.Include(r => r.Items)
.FirstOrDefaultAsync(r => r.Id == input.ReadingListId && r.UserId == user.Id);
if (readingList == null)
{
return new ReadingListPayload { Success = false };
}
var item = readingList.Items.FirstOrDefault(i => i.NovelId == input.NovelId);
if (item == null)
{
throw new InvalidOperationException("Novel not found in reading list");
}
var oldOrder = item.Order;
var newOrder = input.NewOrder;
// Shift other items
if (newOrder < oldOrder)
{
// Moving up: shift items between newOrder and oldOrder down
foreach (var i in readingList.Items.Where(x => x.Order >= newOrder && x.Order < oldOrder))
{
i.Order++;
}
}
else if (newOrder > oldOrder)
{
// Moving down: shift items between oldOrder and newOrder up
foreach (var i in readingList.Items.Where(x => x.Order > oldOrder && x.Order <= newOrder))
{
i.Order--;
}
}
item.Order = newOrder;
await dbContext.SaveChangesAsync();
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
ItemCount = readingList.Items.Count,
CreatedTime = readingList.CreatedTime
}
};
}
}

View File

@@ -0,0 +1,136 @@
using System.Security.Claims;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.DTOs;
using FictionArchive.Service.UserNovelDataService.Services;
using HotChocolate.Authorization;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.GraphQL;
public class Query
{
[Authorize]
public async Task<IQueryable<BookmarkDto>> GetBookmarks(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
uint novelId)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
return new List<BookmarkDto>().AsQueryable();
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new List<BookmarkDto>().AsQueryable();
}
return dbContext.Bookmarks
.AsNoTracking()
.Where(b => b.UserId == user.Id && b.NovelId == novelId)
.OrderByDescending(b => b.CreatedTime)
.Select(b => new BookmarkDto
{
Id = b.Id,
ChapterId = b.ChapterId,
NovelId = b.NovelId,
Description = b.Description,
CreatedTime = b.CreatedTime
});
}
[Authorize]
public async Task<IEnumerable<ReadingListDto>> GetReadingLists(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
return [];
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return [];
}
var lists = await dbContext.ReadingLists
.AsNoTracking()
.Include(r => r.Items)
.Where(r => r.UserId == user.Id)
.OrderByDescending(r => r.LastUpdatedTime)
.ToListAsync();
return lists.Select(r => new ReadingListDto
{
Id = r.Id,
Name = r.Name,
Description = r.Description,
ItemCount = r.Items.Count,
Items = r.Items.Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
CreatedTime = r.CreatedTime
});
}
[Authorize]
public async Task<ReadingListDto?> GetReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
int id)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
return null;
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return null;
}
var readingList = await dbContext.ReadingLists
.AsNoTracking()
.Include(r => r.Items.OrderBy(i => i.Order))
.FirstOrDefaultAsync(r => r.Id == id && r.UserId == user.Id);
if (readingList == null)
{
return null;
}
return new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
ItemCount = readingList.Items.Count,
Items = readingList.Items.Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
CreatedTime = readingList.CreatedTime
};
}
}

View File

@@ -0,0 +1,99 @@
// <auto-generated />
using System;
using FictionArchive.Service.UserNovelDataService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.UserNovelDataService.Migrations
{
[DbContext(typeof(UserNovelDataServiceDbContext))]
[Migration("20251230181559_AddBookmarks")]
partial class AddBookmarks
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ChapterId")
.IsUnique();
b.HasIndex("UserId", "NovelId");
b.ToTable("Bookmarks");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("OAuthProviderId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,76 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.UserNovelDataService.Migrations
{
/// <inheritdoc />
public partial class AddBookmarks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OAuthProviderId = table.Column<string>(type: "text", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Bookmarks",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ChapterId = table.Column<long>(type: "bigint", nullable: false),
NovelId = table.Column<long>(type: "bigint", nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Bookmarks", x => x.Id);
table.ForeignKey(
name: "FK_Bookmarks_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Bookmarks_UserId_ChapterId",
table: "Bookmarks",
columns: new[] { "UserId", "ChapterId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Bookmarks_UserId_NovelId",
table: "Bookmarks",
columns: new[] { "UserId", "NovelId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Bookmarks");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@@ -0,0 +1,198 @@
// <auto-generated />
using System;
using FictionArchive.Service.UserNovelDataService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.UserNovelDataService.Migrations
{
[DbContext(typeof(UserNovelDataServiceDbContext))]
[Migration("20260119184741_AddNovelVolumeChapter")]
partial class AddNovelVolumeChapter
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ChapterId")
.IsUnique();
b.HasIndex("UserId", "NovelId");
b.ToTable("Bookmarks");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("VolumeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapters");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Novels");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("OAuthProviderId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("NovelId");
b.ToTable("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel")
.WithMany("Volumes")
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Novel");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b =>
{
b.Navigation("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,95 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.UserNovelDataService.Migrations
{
/// <inheritdoc />
public partial class AddNovelVolumeChapter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Novels",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Novels", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Volumes",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
NovelId = table.Column<long>(type: "bigint", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Volumes", x => x.Id);
table.ForeignKey(
name: "FK_Volumes_Novels_NovelId",
column: x => x.NovelId,
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Chapters",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
VolumeId = table.Column<long>(type: "bigint", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chapters", x => x.Id);
table.ForeignKey(
name: "FK_Chapters_Volumes_VolumeId",
column: x => x.VolumeId,
principalTable: "Volumes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Chapters_VolumeId",
table: "Chapters",
column: "VolumeId");
migrationBuilder.CreateIndex(
name: "IX_Volumes_NovelId",
table: "Volumes",
column: "NovelId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Chapters");
migrationBuilder.DropTable(
name: "Volumes");
migrationBuilder.DropTable(
name: "Novels");
}
}
}

View File

@@ -0,0 +1,289 @@
// <auto-generated />
using System;
using FictionArchive.Service.UserNovelDataService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.UserNovelDataService.Migrations
{
[DbContext(typeof(UserNovelDataServiceDbContext))]
[Migration("20260120014840_AddReadingLists")]
partial class AddReadingLists
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ChapterId")
.IsUnique();
b.HasIndex("UserId", "NovelId");
b.ToTable("Bookmarks");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("VolumeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapters");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Novels");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("ReadingLists");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<int>("ReadingListId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ReadingListId", "NovelId")
.IsUnique();
b.HasIndex("ReadingListId", "Order");
b.ToTable("ReadingListItems");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("OAuthProviderId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("NovelId");
b.ToTable("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", "ReadingList")
.WithMany("Items")
.HasForeignKey("ReadingListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ReadingList");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel")
.WithMany("Volumes")
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Novel");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b =>
{
b.Navigation("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,89 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.UserNovelDataService.Migrations
{
/// <inheritdoc />
public partial class AddReadingLists : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ReadingLists",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReadingLists", x => x.Id);
table.ForeignKey(
name: "FK_ReadingLists_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ReadingListItems",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ReadingListId = table.Column<int>(type: "integer", nullable: false),
NovelId = table.Column<long>(type: "bigint", nullable: false),
Order = table.Column<int>(type: "integer", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReadingListItems", x => x.Id);
table.ForeignKey(
name: "FK_ReadingListItems_ReadingLists_ReadingListId",
column: x => x.ReadingListId,
principalTable: "ReadingLists",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ReadingListItems_ReadingListId_NovelId",
table: "ReadingListItems",
columns: new[] { "ReadingListId", "NovelId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ReadingListItems_ReadingListId_Order",
table: "ReadingListItems",
columns: new[] { "ReadingListId", "Order" });
migrationBuilder.CreateIndex(
name: "IX_ReadingLists_UserId",
table: "ReadingLists",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ReadingListItems");
migrationBuilder.DropTable(
name: "ReadingLists");
}
}
}

View File

@@ -0,0 +1,286 @@
// <auto-generated />
using System;
using FictionArchive.Service.UserNovelDataService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.UserNovelDataService.Migrations
{
[DbContext(typeof(UserNovelDataServiceDbContext))]
partial class UserNovelDataServiceDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ChapterId")
.IsUnique();
b.HasIndex("UserId", "NovelId");
b.ToTable("Bookmarks");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("VolumeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapters");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Novels");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("ReadingLists");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<int>("ReadingListId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ReadingListId", "NovelId")
.IsUnique();
b.HasIndex("ReadingListId", "Order");
b.ToTable("ReadingListItems");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("OAuthProviderId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("NovelId");
b.ToTable("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", "ReadingList")
.WithMany("Items")
.HasForeignKey("ReadingListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ReadingList");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel")
.WithMany("Volumes")
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Novel");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b =>
{
b.Navigation("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,3 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public record AddToReadingListInput(int ReadingListId, uint NovelId);

View File

@@ -0,0 +1,12 @@
using NodaTime;
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class BookmarkDto
{
public int Id { get; init; }
public uint ChapterId { get; init; }
public uint NovelId { get; init; }
public string? Description { get; init; }
public Instant CreatedTime { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class BookmarkPayload
{
public BookmarkDto? Bookmark { get; init; }
public bool Success { get; init; }
}

View File

@@ -0,0 +1,3 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public record CreateReadingListInput(string Name, string? Description);

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class DeleteReadingListPayload
{
public bool Success { get; init; }
}

View File

@@ -0,0 +1,13 @@
using NodaTime;
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class ReadingListDto
{
public int Id { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public IEnumerable<ReadingListItemDto> Items { get; init; } = [];
public int ItemCount { get; init; }
public Instant CreatedTime { get; init; }
}

View File

@@ -0,0 +1,10 @@
using NodaTime;
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class ReadingListItemDto
{
public uint NovelId { get; init; }
public int Order { get; init; }
public Instant AddedTime { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class ReadingListPayload
{
public ReadingListDto? ReadingList { get; init; }
public bool Success { get; init; }
}

View File

@@ -0,0 +1,3 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public record ReorderReadingListItemInput(int ReadingListId, uint NovelId, int NewOrder);

View File

@@ -0,0 +1,3 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public record UpdateReadingListInput(int Id, string Name, string? Description);

View File

@@ -0,0 +1,3 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public record UpsertBookmarkInput(uint NovelId, uint ChapterId, string? Description);

View File

@@ -0,0 +1,14 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class Bookmark : BaseEntity<int>
{
public Guid UserId { get; set; }
public virtual User User { get; set; } = null!;
public uint ChapterId { get; set; }
public uint NovelId { get; set; }
public string? Description { get; set; }
}

View File

@@ -0,0 +1,9 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class Chapter : BaseEntity<uint>
{
public uint VolumeId { get; set; }
public virtual Volume Volume { get; set; } = null!;
}

View File

@@ -0,0 +1,8 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class Novel : BaseEntity<uint>
{
public virtual ICollection<Volume> Volumes { get; set; } = new List<Volume>();
}

View File

@@ -0,0 +1,14 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class ReadingList : BaseEntity<int>
{
public Guid UserId { get; set; }
public virtual User User { get; set; } = null!;
public required string Name { get; set; }
public string? Description { get; set; }
public virtual ICollection<ReadingListItem> Items { get; set; } = new List<ReadingListItem>();
}

View File

@@ -0,0 +1,12 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class ReadingListItem : BaseEntity<int>
{
public int ReadingListId { get; set; }
public virtual ReadingList ReadingList { get; set; } = null!;
public uint NovelId { get; set; }
public int Order { get; set; }
}

View File

@@ -0,0 +1,8 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class User : BaseEntity<Guid>
{
public required string OAuthProviderId { get; set; }
}

View File

@@ -0,0 +1,10 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class Volume : BaseEntity<uint>
{
public uint NovelId { get; set; }
public virtual Novel Novel { get; set; } = null!;
public virtual ICollection<Chapter> Chapters { get; set; } = new List<Chapter>();
}

View File

@@ -0,0 +1,13 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
public class ChapterCreatedEvent : IIntegrationEvent
{
public required uint ChapterId { get; init; }
public required uint NovelId { get; init; }
public required uint VolumeId { get; init; }
public required int VolumeOrder { get; init; }
public required uint ChapterOrder { get; init; }
public required string ChapterTitle { get; init; }
}

View File

@@ -0,0 +1,13 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
public class NovelCreatedEvent : IIntegrationEvent
{
public required uint NovelId { get; init; }
public required string Title { get; init; }
public required Language OriginalLanguage { get; init; }
public required string Source { get; init; }
public required string AuthorName { get; init; }
}

View File

@@ -0,0 +1,15 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
public class UserInvitedEvent : IIntegrationEvent
{
public Guid InvitedUserId { get; set; }
public required string InvitedUsername { get; set; }
public required string InvitedEmail { get; set; }
public required string InvitedOAuthProviderId { get; set; }
public Guid InviterId { get; set; }
public required string InviterUsername { get; set; }
public required string InviterOAuthProviderId { get; set; }
}

View File

@@ -0,0 +1,80 @@
using FictionArchive.Common.Extensions;
using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.UserNovelDataService.GraphQL;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
using FictionArchive.Service.UserNovelDataService.Services;
using FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
namespace FictionArchive.Service.UserNovelDataService;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
builder.AddLocalAppsettings();
builder.Services.AddMemoryCache();
builder.Services.AddHealthChecks();
#region Event Bus
if (!isSchemaExport)
{
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<NovelCreatedEvent, NovelCreatedEventHandler>()
.Subscribe<ChapterCreatedEvent, ChapterCreatedEventHandler>()
.Subscribe<UserInvitedEvent, UserInvitedEventHandler>();
}
#endregion
#region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>()
.AddAuthorization();
#endregion
#region Database
builder.Services.RegisterDbContext<UserNovelDataServiceDbContext>(
builder.Configuration.GetConnectionString("DefaultConnection"),
skipInfrastructure: isSchemaExport);
#endregion
// Authentication & Authorization
builder.Services.AddOidcAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
var app = builder.Build();
// Update database (skip in schema export mode)
if (!isSchemaExport)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<UserNovelDataServiceDbContext>();
dbContext.UpdateDatabase();
}
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.RunWithGraphQLCommands(args);
}
}

View File

@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:26318",
"sslPort": 44303
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5130",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7298;http://localhost:5130",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,93 @@
# UserNovelDataService Backfill Scripts
SQL scripts for backfilling data from UserService and NovelService into UserNovelDataService.
## Prerequisites
1. **Run EF migrations** on the UserNovelDataService database to ensure all tables exist:
```bash
dotnet ef database update --project FictionArchive.Service.UserNovelDataService
```
This will apply the `AddNovelVolumeChapter` migration which creates:
- `Novels` table (Id, CreatedTime, LastUpdatedTime)
- `Volumes` table (Id, NovelId FK, CreatedTime, LastUpdatedTime)
- `Chapters` table (Id, VolumeId FK, CreatedTime, LastUpdatedTime)
## Execution Order
Run scripts in numeric order:
### Extraction (run against source databases)
1. `01_extract_users_from_userservice.sql` - Run against **UserService** DB
2. `02_extract_novels_from_novelservice.sql` - Run against **NovelService** DB
3. `03_extract_volumes_from_novelservice.sql` - Run against **NovelService** DB
4. `04_extract_chapters_from_novelservice.sql` - Run against **NovelService** DB
### Insertion (run against UserNovelDataService database)
5. `05_insert_users_to_usernoveldataservice.sql`
6. `06_insert_novels_to_usernoveldataservice.sql`
7. `07_insert_volumes_to_usernoveldataservice.sql`
8. `08_insert_chapters_to_usernoveldataservice.sql`
## Methods
Each script provides three options:
1. **SELECT for review** - Review data before export
2. **Generate INSERT statements** - Creates individual INSERT statements (good for small datasets)
3. **CSV export/import** - Use PostgreSQL `\copy` for bulk operations (recommended for large datasets)
## Example Workflow
### Using CSV Export/Import (Recommended)
```bash
# 1. Export from source databases
psql -h localhost -U postgres -d userservice -c "\copy (SELECT \"Id\", \"OAuthProviderId\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Users\" WHERE \"Disabled\" = false) TO '/tmp/users_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d novelservice -c "\copy (SELECT \"Id\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Novels\") TO '/tmp/novels_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d novelservice -c "\copy (SELECT \"Id\", \"NovelId\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Volume\" ORDER BY \"NovelId\", \"Id\") TO '/tmp/volumes_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d novelservice -c "\copy (SELECT \"Id\", \"VolumeId\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Chapter\" ORDER BY \"VolumeId\", \"Id\") TO '/tmp/chapters_export.csv' WITH CSV HEADER"
# 2. Import into UserNovelDataService (order matters due to FK constraints!)
psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Users\" (\"Id\", \"OAuthProviderId\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/users_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Novels\" (\"Id\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/novels_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Volumes\" (\"Id\", \"NovelId\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/volumes_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Chapters\" (\"Id\", \"VolumeId\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/chapters_export.csv' WITH CSV HEADER"
```
**Important**: Insert order matters due to foreign key constraints:
1. Users (no dependencies)
2. Novels (no dependencies)
3. Volumes (depends on Novels)
4. Chapters (depends on Volumes)
### Using dblink (Cross-database queries)
If both databases are on the same PostgreSQL server, you can use `dblink` extension for direct cross-database inserts. See the commented examples in each insert script.
## Verification
After running the backfill, verify counts match:
```sql
-- Run on UserService DB
SELECT COUNT(*) as user_count FROM "Users" WHERE "Disabled" = false;
-- Run on NovelService DB
SELECT COUNT(*) as novel_count FROM "Novels";
SELECT COUNT(*) as volume_count FROM "Volume";
SELECT COUNT(*) as chapter_count FROM "Chapter";
-- Run on UserNovelDataService DB
SELECT COUNT(*) as user_count FROM "Users";
SELECT COUNT(*) as novel_count FROM "Novels";
SELECT COUNT(*) as volume_count FROM "Volumes";
SELECT COUNT(*) as chapter_count FROM "Chapters";
```

View File

@@ -0,0 +1,28 @@
-- Extract Users from UserService database
-- Run this against: UserService PostgreSQL database
-- Output: CSV or use COPY TO for bulk export
-- Option 1: Simple SELECT for review/testing
SELECT
"Id",
"OAuthProviderId",
"CreatedTime",
"LastUpdatedTime"
FROM "Users"
WHERE "Disabled" = false
ORDER BY "CreatedTime";
-- Option 2: Generate INSERT statements (useful for small datasets)
SELECT format(
'INSERT INTO "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime") VALUES (%L, %L, %L, %L) ON CONFLICT ("Id") DO NOTHING;',
"Id",
"OAuthProviderId",
"CreatedTime",
"LastUpdatedTime"
)
FROM "Users"
WHERE "Disabled" = false
ORDER BY "CreatedTime";
-- Option 3: Export to CSV (run from psql)
-- \copy (SELECT "Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime" FROM "Users" WHERE "Disabled" = false ORDER BY "CreatedTime") TO '/tmp/users_export.csv' WITH CSV HEADER;

View File

@@ -0,0 +1,24 @@
-- Extract Novels from NovelService database
-- Run this against: NovelService PostgreSQL database
-- Output: CSV or use COPY TO for bulk export
-- Option 1: Simple SELECT for review/testing
SELECT
"Id",
"CreatedTime",
"LastUpdatedTime"
FROM "Novels"
ORDER BY "Id";
-- Option 2: Generate INSERT statements
SELECT format(
'INSERT INTO "Novels" ("Id", "CreatedTime", "LastUpdatedTime") VALUES (%s, %L, %L) ON CONFLICT ("Id") DO NOTHING;',
"Id",
"CreatedTime",
"LastUpdatedTime"
)
FROM "Novels"
ORDER BY "Id";
-- Option 3: Export to CSV (run from psql)
-- \copy (SELECT "Id", "CreatedTime", "LastUpdatedTime" FROM "Novels" ORDER BY "Id") TO '/tmp/novels_export.csv' WITH CSV HEADER;

View File

@@ -0,0 +1,26 @@
-- Extract Volumes from NovelService database
-- Run this against: NovelService PostgreSQL database
-- Output: CSV or use COPY TO for bulk export
-- Option 1: Simple SELECT for review/testing
SELECT
"Id",
"NovelId",
"CreatedTime",
"LastUpdatedTime"
FROM "Volume"
ORDER BY "NovelId", "Id";
-- Option 2: Generate INSERT statements
SELECT format(
'INSERT INTO "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime") VALUES (%s, %s, %L, %L) ON CONFLICT ("Id") DO NOTHING;',
"Id",
"NovelId",
"CreatedTime",
"LastUpdatedTime"
)
FROM "Volume"
ORDER BY "NovelId", "Id";
-- Option 3: Export to CSV (run from psql)
-- \copy (SELECT "Id", "NovelId", "CreatedTime", "LastUpdatedTime" FROM "Volume" ORDER BY "NovelId", "Id") TO '/tmp/volumes_export.csv' WITH CSV HEADER;

View File

@@ -0,0 +1,26 @@
-- Extract Chapters from NovelService database
-- Run this against: NovelService PostgreSQL database
-- Output: CSV or use COPY TO for bulk export
-- Option 1: Simple SELECT for review/testing
SELECT
"Id",
"VolumeId",
"CreatedTime",
"LastUpdatedTime"
FROM "Chapter"
ORDER BY "VolumeId", "Id";
-- Option 2: Generate INSERT statements
SELECT format(
'INSERT INTO "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime") VALUES (%s, %s, %L, %L) ON CONFLICT ("Id") DO NOTHING;',
"Id",
"VolumeId",
"CreatedTime",
"LastUpdatedTime"
)
FROM "Chapter"
ORDER BY "VolumeId", "Id";
-- Option 3: Export to CSV (run from psql)
-- \copy (SELECT "Id", "VolumeId", "CreatedTime", "LastUpdatedTime" FROM "Chapter" ORDER BY "VolumeId", "Id") TO '/tmp/chapters_export.csv' WITH CSV HEADER;

View File

@@ -0,0 +1,32 @@
-- Insert Users into UserNovelDataService database
-- Run this against: UserNovelDataService PostgreSQL database
--
-- PREREQUISITE: You must have extracted users from UserService first
-- using 01_extract_users_from_userservice.sql
-- Option 1: If you have a CSV file from export
-- \copy "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime") FROM '/tmp/users_export.csv' WITH CSV HEADER;
-- Option 2: Direct cross-database insert using dblink
-- First, install dblink extension if not already done:
-- CREATE EXTENSION IF NOT EXISTS dblink;
-- Example using dblink (adjust connection string):
/*
INSERT INTO "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime")
SELECT
"Id"::uuid,
"OAuthProviderId",
"CreatedTime"::timestamp with time zone,
"LastUpdatedTime"::timestamp with time zone
FROM dblink(
'host=localhost port=5432 dbname=userservice user=postgres password=yourpassword',
'SELECT "Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime" FROM "Users" WHERE "Disabled" = false'
) AS t("Id" uuid, "OAuthProviderId" text, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone)
ON CONFLICT ("Id") DO UPDATE SET
"OAuthProviderId" = EXCLUDED."OAuthProviderId",
"LastUpdatedTime" = EXCLUDED."LastUpdatedTime";
*/
-- Option 3: Paste generated INSERT statements from extraction script here
-- INSERT INTO "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING;

View File

@@ -0,0 +1,31 @@
-- Insert Novels into UserNovelDataService database
-- Run this against: UserNovelDataService PostgreSQL database
--
-- PREREQUISITE:
-- 1. Ensure the Novels table exists (run EF migrations first if needed)
-- 2. Extract novels from NovelService using 02_extract_novels_from_novelservice.sql
-- Option 1: If you have a CSV file from export
-- \copy "Novels" ("Id", "CreatedTime", "LastUpdatedTime") FROM '/tmp/novels_export.csv' WITH CSV HEADER;
-- Option 2: Direct cross-database insert using dblink
-- First, install dblink extension if not already done:
-- CREATE EXTENSION IF NOT EXISTS dblink;
-- Example using dblink (adjust connection string):
/*
INSERT INTO "Novels" ("Id", "CreatedTime", "LastUpdatedTime")
SELECT
"Id"::bigint,
"CreatedTime"::timestamp with time zone,
"LastUpdatedTime"::timestamp with time zone
FROM dblink(
'host=localhost port=5432 dbname=novelservice user=postgres password=yourpassword',
'SELECT "Id", "CreatedTime", "LastUpdatedTime" FROM "Novels"'
) AS t("Id" bigint, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone)
ON CONFLICT ("Id") DO UPDATE SET
"LastUpdatedTime" = EXCLUDED."LastUpdatedTime";
*/
-- Option 3: Paste generated INSERT statements from extraction script here
-- INSERT INTO "Novels" ("Id", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING;

View File

@@ -0,0 +1,34 @@
-- Insert Volumes into UserNovelDataService database
-- Run this against: UserNovelDataService PostgreSQL database
--
-- PREREQUISITE:
-- 1. Ensure the Volumes table exists (run EF migrations first if needed)
-- 2. Novels must be inserted first (FK constraint)
-- 3. Extract volumes from NovelService using 03_extract_volumes_from_novelservice.sql
-- Option 1: If you have a CSV file from export
-- \copy "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime") FROM '/tmp/volumes_export.csv' WITH CSV HEADER;
-- Option 2: Direct cross-database insert using dblink
-- First, install dblink extension if not already done:
-- CREATE EXTENSION IF NOT EXISTS dblink;
-- Example using dblink (adjust connection string):
/*
INSERT INTO "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime")
SELECT
"Id"::bigint,
"NovelId"::bigint,
"CreatedTime"::timestamp with time zone,
"LastUpdatedTime"::timestamp with time zone
FROM dblink(
'host=localhost port=5432 dbname=novelservice user=postgres password=yourpassword',
'SELECT "Id", "NovelId", "CreatedTime", "LastUpdatedTime" FROM "Volume"'
) AS t("Id" bigint, "NovelId" bigint, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone)
ON CONFLICT ("Id") DO UPDATE SET
"NovelId" = EXCLUDED."NovelId",
"LastUpdatedTime" = EXCLUDED."LastUpdatedTime";
*/
-- Option 3: Paste generated INSERT statements from extraction script here
-- INSERT INTO "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING;

View File

@@ -0,0 +1,34 @@
-- Insert Chapters into UserNovelDataService database
-- Run this against: UserNovelDataService PostgreSQL database
--
-- PREREQUISITE:
-- 1. Ensure the Chapters table exists (run EF migrations first if needed)
-- 2. Volumes must be inserted first (FK constraint)
-- 3. Extract chapters from NovelService using 04_extract_chapters_from_novelservice.sql
-- Option 1: If you have a CSV file from export
-- \copy "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime") FROM '/tmp/chapters_export.csv' WITH CSV HEADER;
-- Option 2: Direct cross-database insert using dblink
-- First, install dblink extension if not already done:
-- CREATE EXTENSION IF NOT EXISTS dblink;
-- Example using dblink (adjust connection string):
/*
INSERT INTO "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime")
SELECT
"Id"::bigint,
"VolumeId"::bigint,
"CreatedTime"::timestamp with time zone,
"LastUpdatedTime"::timestamp with time zone
FROM dblink(
'host=localhost port=5432 dbname=novelservice user=postgres password=yourpassword',
'SELECT "Id", "VolumeId", "CreatedTime", "LastUpdatedTime" FROM "Chapter"'
) AS t("Id" bigint, "VolumeId" bigint, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone)
ON CONFLICT ("Id") DO UPDATE SET
"VolumeId" = EXCLUDED."VolumeId",
"LastUpdatedTime" = EXCLUDED."LastUpdatedTime";
*/
-- Option 3: Paste generated INSERT statements from extraction script here
-- INSERT INTO "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING;

View File

@@ -0,0 +1,53 @@
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
public class ChapterCreatedEventHandler : IIntegrationEventHandler<ChapterCreatedEvent>
{
private readonly UserNovelDataServiceDbContext _dbContext;
private readonly ILogger<ChapterCreatedEventHandler> _logger;
public ChapterCreatedEventHandler(
UserNovelDataServiceDbContext dbContext,
ILogger<ChapterCreatedEventHandler> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task Handle(ChapterCreatedEvent @event)
{
// Ensure novel exists
var novelExists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId);
if (!novelExists)
{
var novel = new Novel { Id = @event.NovelId };
_dbContext.Novels.Add(novel);
}
// Ensure volume exists
var volumeExists = await _dbContext.Volumes.AnyAsync(v => v.Id == @event.VolumeId);
if (!volumeExists)
{
var volume = new Volume { Id = @event.VolumeId };
_dbContext.Volumes.Add(volume);
}
// Create chapter if not exists
var chapterExists = await _dbContext.Chapters.AnyAsync(c => c.Id == @event.ChapterId);
if (chapterExists)
{
_logger.LogDebug("Chapter {ChapterId} already exists, skipping", @event.ChapterId);
return;
}
var chapter = new Chapter { Id = @event.ChapterId };
_dbContext.Chapters.Add(chapter);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Created chapter stub for {ChapterId} in novel {NovelId}", @event.ChapterId, @event.NovelId);
}
}

View File

@@ -0,0 +1,36 @@
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
public class NovelCreatedEventHandler : IIntegrationEventHandler<NovelCreatedEvent>
{
private readonly UserNovelDataServiceDbContext _dbContext;
private readonly ILogger<NovelCreatedEventHandler> _logger;
public NovelCreatedEventHandler(
UserNovelDataServiceDbContext dbContext,
ILogger<NovelCreatedEventHandler> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task Handle(NovelCreatedEvent @event)
{
var exists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId);
if (exists)
{
_logger.LogDebug("Novel {NovelId} already exists, skipping", @event.NovelId);
return;
}
var novel = new Novel { Id = @event.NovelId };
_dbContext.Novels.Add(novel);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Created novel stub for {NovelId}", @event.NovelId);
}
}

View File

@@ -0,0 +1,40 @@
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
public class UserInvitedEventHandler : IIntegrationEventHandler<UserInvitedEvent>
{
private readonly UserNovelDataServiceDbContext _dbContext;
private readonly ILogger<UserInvitedEventHandler> _logger;
public UserInvitedEventHandler(
UserNovelDataServiceDbContext dbContext,
ILogger<UserInvitedEventHandler> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task Handle(UserInvitedEvent @event)
{
var exists = await _dbContext.Users.AnyAsync(u => u.Id == @event.InvitedUserId);
if (exists)
{
_logger.LogDebug("User {UserId} already exists, skipping", @event.InvitedUserId);
return;
}
var user = new User
{
Id = @event.InvitedUserId,
OAuthProviderId = @event.InvitedOAuthProviderId
};
_dbContext.Users.Add(user);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Created user stub for {UserId}", @event.InvitedUserId);
}
}

View File

@@ -0,0 +1,67 @@
using FictionArchive.Service.Shared.Services.Database;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.Services;
public class UserNovelDataServiceDbContext : FictionArchiveDbContext
{
public DbSet<User> Users { get; set; }
public DbSet<Bookmark> Bookmarks { get; set; }
public DbSet<Novel> Novels { get; set; }
public DbSet<Volume> Volumes { get; set; }
public DbSet<Chapter> Chapters { get; set; }
public DbSet<ReadingList> ReadingLists { get; set; }
public DbSet<ReadingListItem> ReadingListItems { get; set; }
public UserNovelDataServiceDbContext(DbContextOptions options, ILogger<UserNovelDataServiceDbContext> logger) : base(options, logger)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Bookmark>(entity =>
{
// Unique constraint: one bookmark per chapter per user
entity.HasIndex(b => new { b.UserId, b.ChapterId }).IsUnique();
// Index for efficient "get bookmarks for novel" queries
entity.HasIndex(b => new { b.UserId, b.NovelId });
// User relationship
entity.HasOne(b => b.User)
.WithMany()
.HasForeignKey(b => b.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ReadingList>(entity =>
{
// Index for fetching user's lists
entity.HasIndex(r => r.UserId);
// User relationship with cascade delete
entity.HasOne(r => r.User)
.WithMany()
.HasForeignKey(r => r.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ReadingListItem>(entity =>
{
// Unique constraint: one entry per novel per list
entity.HasIndex(i => new { i.ReadingListId, i.NovelId }).IsUnique();
// Index for efficient ordered retrieval
entity.HasIndex(i => new { i.ReadingListId, i.Order });
// ReadingList relationship with cascade delete
entity.HasOne(i => i.ReadingList)
.WithMany(r => r.Items)
.HasForeignKey(i => i.ReadingListId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,26 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_UserNovelDataService;Username=postgres;password=postgres"
},
"RabbitMQ": {
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "UserNovelDataService"
},
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,6 @@
{
"subgraph": "UserNovelData",
"http": {
"baseAddress": "https://localhost:7298/graphql"
}
}

View File

@@ -213,10 +213,10 @@ public class UserManagementServiceTests
dbContext.Users.Add(inviter);
await dbContext.SaveChangesAsync();
var authentikUid = "authentik-uid-789";
var authentikPk = 456;
var authClient = Substitute.For<IAuthenticationServiceClient>();
authClient.CreateUserAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(new AuthentikUserResponse { Pk = 456, Uid = authentikUid });
.Returns(new AuthentikUserResponse { Pk = authentikPk, Uid = "authentik-uid-789" });
authClient.SendRecoveryEmailAsync(Arg.Any<int>()).Returns(true);
var service = CreateService(dbContext, authClient);
@@ -228,7 +228,7 @@ public class UserManagementServiceTests
result.Should().NotBeNull();
result!.Username.Should().Be("newusername");
result.Email.Should().Be("newuser@test.com");
result.OAuthProviderId.Should().Be(authentikUid);
result.OAuthProviderId.Should().Be(authentikPk.ToString());
result.InviterId.Should().Be(inviter.Id);
result.AvailableInvites.Should().Be(0);
result.Disabled.Should().BeFalse();

View File

@@ -86,7 +86,7 @@ public class UserManagementService
{
Username = username,
Email = email,
OAuthProviderId = authentikUser.Uid,
OAuthProviderId = authentikUser.Pk.ToString(),
Disabled = false,
AvailableInvites = 0,
InviterId = inviter.Id

View File

@@ -1,6 +1,6 @@

Microsoft Visual Studio Solution File, Format Version 12.00
#
#
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Common", "FictionArchive.Common\FictionArchive.Common.csproj", "{ABF1BA10-9E76-45BE-9947-E20445A68147}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.API", "FictionArchive.API\FictionArchive.API.csproj", "{420CC1A1-9DBC-40EC-B9E3-D4B25D71B9A9}"
@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Nove
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserService.Tests", "FictionArchive.Service.UserService.Tests\FictionArchive.Service.UserService.Tests.csproj", "{10C38C89-983D-4544-8911-F03099F66AB8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserNovelDataService", "FictionArchive.Service.UserNovelDataService\FictionArchive.Service.UserNovelDataService.csproj", "{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -67,5 +69,9 @@ Global
{10C38C89-983D-4544-8911-F03099F66AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10C38C89-983D-4544-8911-F03099F66AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10C38C89-983D-4544-8911-F03099F66AB8}.Release|Any CPU.Build.0 = Release|Any CPU
{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -4,25 +4,34 @@ services:
# ===========================================
postgres:
image: postgres:16-alpine
networks:
fictionarchive:
ipv4_address: 172.20.0.10
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- postgres_data:/var/lib/postgresql/data
- /srv/docker_volumes/fictionarchive/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
ports:
- 4321:5432
rabbitmq:
image: rabbitmq:3-management-alpine
networks:
fictionarchive:
ipv4_address: 172.20.0.11
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest}
RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: -rabbit max_message_size 536870912
volumes:
- rabbitmq_data:/var/lib/rabbitmq
- /srv/docker_volumes/fictionarchive/rabbitmq:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_running"]
interval: 10s
@@ -30,6 +39,37 @@ services:
retries: 5
restart: unless-stopped
# ===========================================
# VPN Container
# ===========================================
vpn:
image: dperson/openvpn-client
networks:
fictionarchive:
ipv4_address: 172.20.0.20
aliases:
- novel-service
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun
volumes:
- /srv/docker_volumes/korean_vpn:/vpn
dns:
- 192.168.3.1
environment:
- DNS=1.1.1.1,8.8.8.8
extra_hosts:
- "postgres:172.20.0.10"
- "rabbitmq:172.20.0.11"
healthcheck:
test: ["CMD", "ping", "-c", "1", "-W", "5", "1.1.1.1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped
# ===========================================
# Backend Services
# ===========================================
@@ -37,50 +77,27 @@ services:
image: git.orfl.xyz/conco/fictionarchive-novel-service:latest
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_NovelService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
Novelpia__Username: ${NOVELPIA_USERNAME}
Novelpia__Password: ${NOVELPIA_PASSWORD}
NovelUpdateService__PendingImageUrl: https://files.fictionarchive.orfl.xyz/api/pendingupload.png
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
UpdateService__PendingImageUrl: https://files.fictionarchive.orfl.xyz/api/pendingupload.png
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
translation-service:
image: git.orfl.xyz/conco/fictionarchive-translation-service:latest
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_TranslationService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
DeepL__ApiKey: ${DEEPL_API_KEY}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
postgres:
condition: service_healthy
rabbitmq:
vpn:
condition: service_healthy
network_mode: "service:vpn"
restart: unless-stopped
scheduler-service:
image: git.orfl.xyz/conco/fictionarchive-scheduler-service:latest
networks:
- fictionarchive
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_SchedulerService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
depends_on:
postgres:
condition: service_healthy
@@ -90,14 +107,14 @@ services:
user-service:
image: git.orfl.xyz/conco/fictionarchive-user-service:latest
networks:
- fictionarchive
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_UserService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
Authentik__BaseUrl: https://auth.orfl.xyz
Authentik__ApiToken: ${AUTHENTIK_API_TOKEN}
Authentik__EmailStageId: 10df0c18-8802-4ec7-852e-3cdd355514d3
depends_on:
postgres:
condition: service_healthy
@@ -105,42 +122,35 @@ services:
condition: service_healthy
restart: unless-stopped
authentication-service:
image: git.orfl.xyz/conco/fictionarchive-authentication-service:latest
usernoveldata-service:
image: git.orfl.xyz/conco/fictionarchive-usernoveldata-service:latest
networks:
- fictionarchive
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_UserNovelDataService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
file-service:
image: git.orfl.xyz/conco/fictionarchive-file-service:latest
networks:
- web
- fictionarchive
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
S3__Endpoint: ${S3_ENDPOINT:-https://s3.orfl.xyz}
S3__Bucket: ${S3_BUCKET:-fictionarchive}
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
S3__AccessKey: ${S3_ACCESS_KEY}
S3__SecretKey: ${S3_SECRET_KEY}
Proxy__BaseUrl: https://files.orfl.xyz/api
OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/
OIDC__ClientId: fictionarchive-files
OIDC__Audience: fictionarchive-api
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
ProxyConfiguration__BaseUrl: https://files.fictionarchive.orfl.xyz/api
labels:
- "traefik.enable=true"
- "traefik.http.routers.file-service.rule=Host(`files.orfl.xyz`)"
- "traefik.http.routers.file-service.entrypoints=websecure"
- "traefik.http.routers.file-service.tls.certresolver=letsencrypt"
- "traefik.http.routers.file-service.rule=Host(`files.fictionarchive.orfl.xyz`)"
- "traefik.http.routers.file-service.tls=true"
- "traefik.http.routers.file-service.tls.certresolver=lets-encrypt"
- "traefik.http.services.file-service.loadbalancer.server.port=8080"
depends_on:
rabbitmq:
@@ -152,30 +162,23 @@ services:
# ===========================================
api-gateway:
image: git.orfl.xyz/conco/fictionarchive-api:latest
networks:
- web
- fictionarchive
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/
OIDC__ClientId: fictionarchive-api
OIDC__Audience: fictionarchive-api
Cors__AllowedOrigin: https://fictionarchive.orfl.xyz
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-gateway.rule=Host(`api.fictionarchive.orfl.xyz`)"
- "traefik.http.routers.api-gateway.entrypoints=websecure"
- "traefik.http.routers.api-gateway.tls.certresolver=letsencrypt"
- "traefik.http.routers.api-gateway.tls=true"
- "traefik.http.routers.api-gateway.tls.certresolver=lets-encrypt"
- "traefik.http.services.api-gateway.loadbalancer.server.port=8080"
depends_on:
- novel-service
- translation-service
- scheduler-service
- user-service
- authentication-service
- file-service
- user-service
- usernoveldata-service
restart: unless-stopped
# ===========================================
@@ -183,20 +186,21 @@ services:
# ===========================================
frontend:
image: git.orfl.xyz/conco/fictionarchive-frontend:latest
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.frontend.rule=Host(`fictionarchive.orfl.xyz`)"
- "traefik.http.routers.frontend.entrypoints=websecure"
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
- "traefik.http.services.frontend.loadbalancer.server.port=80"
- traefik.http.routers.fafrontend.rule=Host(`fictionarchive.orfl.xyz`)
- traefik.http.routers.fafrontend.tls=true
- traefik.http.routers.fafrontend.tls.certresolver=lets-encrypt
- traefik.http.services.fafrontend.loadbalancer.server.port=80
- traefik.enable=true
restart: unless-stopped
volumes:
postgres_data:
rabbitmq_data:
letsencrypt:
networks:
web:
external: yes
fictionarchive:
ipam:
driver: default
config:
- subnet: 172.20.0.0/24

View File

@@ -8,6 +8,7 @@
"name": "fictionarchive-web-astro",
"version": "0.0.1",
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/node": "^9.5.1",
"@astrojs/svelte": "^7.2.2",
"@tailwindcss/vite": "^4.1.17",
@@ -142,6 +143,24 @@
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT"
},
"node_modules/@astrojs/check": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.6.tgz",
"integrity": "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA==",
"license": "MIT",
"dependencies": {
"@astrojs/language-server": "^2.16.1",
"chokidar": "^4.0.1",
"kleur": "^4.1.5",
"yargs": "^17.7.2"
},
"bin": {
"astro-check": "bin/astro-check.js"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
},
"node_modules/@astrojs/compiler": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz",
@@ -155,6 +174,47 @@
"integrity": "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==",
"license": "MIT"
},
"node_modules/@astrojs/language-server": {
"version": "2.16.3",
"resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.3.tgz",
"integrity": "sha512-yO5K7RYCMXUfeDlnU6UnmtnoXzpuQc0yhlaCNZ67k1C/MiwwwvMZz+LGa+H35c49w5QBfvtr4w4Zcf5PcH8uYA==",
"license": "MIT",
"dependencies": {
"@astrojs/compiler": "^2.13.0",
"@astrojs/yaml2ts": "^0.2.2",
"@jridgewell/sourcemap-codec": "^1.5.5",
"@volar/kit": "~2.4.27",
"@volar/language-core": "~2.4.27",
"@volar/language-server": "~2.4.27",
"@volar/language-service": "~2.4.27",
"muggle-string": "^0.4.1",
"tinyglobby": "^0.2.15",
"volar-service-css": "0.0.68",
"volar-service-emmet": "0.0.68",
"volar-service-html": "0.0.68",
"volar-service-prettier": "0.0.68",
"volar-service-typescript": "0.0.68",
"volar-service-typescript-twoslash-queries": "0.0.68",
"volar-service-yaml": "0.0.68",
"vscode-html-languageservice": "^5.6.1",
"vscode-uri": "^3.1.0"
},
"bin": {
"astro-ls": "bin/nodeServer.js"
},
"peerDependencies": {
"prettier": "^3.0.0",
"prettier-plugin-astro": ">=0.11.0"
},
"peerDependenciesMeta": {
"prettier": {
"optional": true
},
"prettier-plugin-astro": {
"optional": true
}
}
},
"node_modules/@astrojs/markdown-remark": {
"version": "6.3.9",
"resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.9.tgz",
@@ -247,6 +307,15 @@
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
}
},
"node_modules/@astrojs/yaml2ts": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.2.tgz",
"integrity": "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==",
"license": "MIT",
"dependencies": {
"yaml": "^2.5.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -666,6 +735,61 @@
"node": ">=18"
}
},
"node_modules/@emmetio/abbreviation": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
"integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==",
"license": "MIT",
"dependencies": {
"@emmetio/scanner": "^1.0.4"
}
},
"node_modules/@emmetio/css-abbreviation": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz",
"integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==",
"license": "MIT",
"dependencies": {
"@emmetio/scanner": "^1.0.4"
}
},
"node_modules/@emmetio/css-parser": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz",
"integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==",
"license": "MIT",
"dependencies": {
"@emmetio/stream-reader": "^2.2.0",
"@emmetio/stream-reader-utils": "^0.1.0"
}
},
"node_modules/@emmetio/html-matcher": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz",
"integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==",
"license": "ISC",
"dependencies": {
"@emmetio/scanner": "^1.0.0"
}
},
"node_modules/@emmetio/scanner": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz",
"integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==",
"license": "MIT"
},
"node_modules/@emmetio/stream-reader": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz",
"integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==",
"license": "MIT"
},
"node_modules/@emmetio/stream-reader-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz",
"integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==",
"license": "MIT"
},
"node_modules/@emnapi/runtime": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
@@ -4332,6 +4456,96 @@
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/@volar/kit": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.27.tgz",
"integrity": "sha512-ilZoQDMLzqmSsImJRWx4YiZ4FcvvPrPnFVmL6hSsIWB6Bn3qc7k88J9yP32dagrs5Y8EXIlvvD/mAFaiuEOACQ==",
"license": "MIT",
"dependencies": {
"@volar/language-service": "2.4.27",
"@volar/typescript": "2.4.27",
"typesafe-path": "^0.2.2",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"typescript": "*"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz",
"integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==",
"license": "MIT",
"dependencies": {
"@volar/source-map": "2.4.27"
}
},
"node_modules/@volar/language-server": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.27.tgz",
"integrity": "sha512-SymGNkErcHg8GjiG65iQN8sLkhqu1pwKhFySmxeBuYq5xFYagKBW36eiNITXQTdvT0tutI1GXcXdq/FdE/IyjA==",
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.27",
"@volar/language-service": "2.4.27",
"@volar/typescript": "2.4.27",
"path-browserify": "^1.0.1",
"request-light": "^0.7.0",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@volar/language-service": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.27.tgz",
"integrity": "sha512-SxKZ8yLhpWa7Y5e/RDxtNfm7j7xsXp/uf2urijXEffRNpPSmVdfzQrFFy5d7l8PNpZy+bHg+yakmqBPjQN+MOw==",
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.27",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.27.tgz",
"integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==",
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz",
"integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==",
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.27",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vscode/emmet-helper": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz",
"integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==",
"license": "MIT",
"dependencies": {
"emmet": "^2.4.3",
"jsonc-parser": "^2.3.0",
"vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.15.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vscode/l10n": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
"license": "MIT"
},
"node_modules/@whatwg-node/disposablestack": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz",
@@ -4529,7 +4743,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -5345,7 +5558,6 @@
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
@@ -5360,7 +5572,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5370,14 +5581,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/cliui/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5387,7 +5596,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -5402,7 +5610,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -5415,7 +5622,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -5451,7 +5657,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -5464,7 +5669,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
@@ -6119,6 +6323,22 @@
"dev": true,
"license": "ISC"
},
"node_modules/emmet": {
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
"integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
"license": "MIT",
"workspaces": [
"./packages/scanner",
"./packages/abbreviation",
"./packages/css-abbreviation",
"./"
],
"dependencies": {
"@emmetio/abbreviation": "^2.3.3",
"@emmetio/css-abbreviation": "^2.1.8"
}
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
@@ -6243,7 +6463,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6656,6 +6875,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -6890,7 +7125,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -8145,6 +8379,12 @@
"node": ">=6"
}
},
"node_modules/jsonc-parser": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
"integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
"license": "MIT"
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
@@ -8535,7 +8775,6 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": {
@@ -9641,6 +9880,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"license": "MIT"
},
"node_modules/mute-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
@@ -10082,6 +10327,12 @@
"tslib": "^2.0.3"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/path-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz",
@@ -10317,6 +10568,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
@@ -10628,11 +10895,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/request-light": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz",
"integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==",
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -11721,6 +11993,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typesafe-path": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
"integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -11735,6 +12013,27 @@
"node": ">=14.17"
}
},
"node_modules/typescript-auto-import-cache": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz",
"integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.8"
}
},
"node_modules/typescript-auto-import-cache/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/typescript-eslint": {
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz",
@@ -12319,6 +12618,255 @@
}
}
},
"node_modules/volar-service-css": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.68.tgz",
"integrity": "sha512-lJSMh6f3QzZ1tdLOZOzovLX0xzAadPhx8EKwraDLPxBndLCYfoTvnNuiFFV8FARrpAlW5C0WkH+TstPaCxr00Q==",
"license": "MIT",
"dependencies": {
"vscode-css-languageservice": "^6.3.0",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-emmet": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.68.tgz",
"integrity": "sha512-nHvixrRQ83EzkQ4G/jFxu9Y4eSsXS/X2cltEPDM+K9qZmIv+Ey1w0tg1+6caSe8TU5Hgw4oSTwNMf/6cQb3LzQ==",
"license": "MIT",
"dependencies": {
"@emmetio/css-parser": "^0.4.1",
"@emmetio/html-matcher": "^1.3.0",
"@vscode/emmet-helper": "^2.9.3",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-html": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.68.tgz",
"integrity": "sha512-fru9gsLJxy33xAltXOh4TEdi312HP80hpuKhpYQD4O5hDnkNPEBdcQkpB+gcX0oK0VxRv1UOzcGQEUzWCVHLfA==",
"license": "MIT",
"dependencies": {
"vscode-html-languageservice": "^5.3.0",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-prettier": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.68.tgz",
"integrity": "sha512-grUmWHkHlebMOd6V8vXs2eNQUw/bJGJMjekh/EPf/p2ZNTK0Uyz7hoBRngcvGfJHMsSXZH8w/dZTForIW/4ihw==",
"license": "MIT",
"dependencies": {
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0",
"prettier": "^2.2 || ^3.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
},
"prettier": {
"optional": true
}
}
},
"node_modules/volar-service-typescript": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.68.tgz",
"integrity": "sha512-z7B/7CnJ0+TWWFp/gh2r5/QwMObHNDiQiv4C9pTBNI2Wxuwymd4bjEORzrJ/hJ5Yd5+OzeYK+nFCKevoGEEeKw==",
"license": "MIT",
"dependencies": {
"path-browserify": "^1.0.1",
"semver": "^7.6.2",
"typescript-auto-import-cache": "^0.3.5",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-nls": "^5.2.0",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-typescript-twoslash-queries": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.68.tgz",
"integrity": "sha512-NugzXcM0iwuZFLCJg47vI93su5YhTIweQuLmZxvz5ZPTaman16JCvmDZexx2rd5T/75SNuvvZmrTOTNYUsfe5w==",
"license": "MIT",
"dependencies": {
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-typescript/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/volar-service-yaml": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.68.tgz",
"integrity": "sha512-84XgE02LV0OvTcwfqhcSwVg4of3MLNUWPMArO6Aj8YXqyEVnPu8xTEMY2btKSq37mVAPuaEVASI4e3ptObmqcA==",
"license": "MIT",
"dependencies": {
"vscode-uri": "^3.0.8",
"yaml-language-server": "~1.19.2"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/vscode-css-languageservice": {
"version": "6.3.9",
"resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.9.tgz",
"integrity": "sha512-1tLWfp+TDM5ZuVWht3jmaY5y7O6aZmpeXLoHl5bv1QtRsRKt4xYGRMmdJa5Pqx/FTkgRbsna9R+Gn2xE+evVuA==",
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"vscode-languageserver-textdocument": "^1.0.12",
"vscode-languageserver-types": "3.17.5",
"vscode-uri": "^3.1.0"
}
},
"node_modules/vscode-html-languageservice": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.1.tgz",
"integrity": "sha512-5Mrqy5CLfFZUgkyhNZLA1Ye5g12Cb/v6VM7SxUzZUaRKWMDz4md+y26PrfRTSU0/eQAl3XpO9m2og+GGtDMuaA==",
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"vscode-languageserver-textdocument": "^1.0.12",
"vscode-languageserver-types": "^3.17.5",
"vscode-uri": "^3.1.0"
}
},
"node_modules/vscode-json-languageservice": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz",
"integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==",
"license": "MIT",
"dependencies": {
"jsonc-parser": "^3.0.0",
"vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.16.0",
"vscode-nls": "^5.0.0",
"vscode-uri": "^3.0.2"
},
"engines": {
"npm": ">=7.0.0"
}
},
"node_modules/vscode-json-languageservice/node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT"
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
},
"bin": {
"installServerIntoExtension": "bin/installServerIntoExtension"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-nls": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz",
"integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@@ -12575,7 +13123,6 @@
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
@@ -12592,7 +13139,6 @@
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"devOptional": true,
"license": "ISC",
"peer": true,
"bin": {
@@ -12605,11 +13151,88 @@
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yaml-language-server": {
"version": "1.19.2",
"resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.19.2.tgz",
"integrity": "sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg==",
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"ajv": "^8.17.1",
"ajv-draft-04": "^1.0.0",
"lodash": "4.17.21",
"prettier": "^3.5.0",
"request-light": "^0.5.7",
"vscode-json-languageservice": "4.1.8",
"vscode-languageserver": "^9.0.0",
"vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.16.0",
"vscode-uri": "^3.0.2",
"yaml": "2.7.1"
},
"bin": {
"yaml-language-server": "bin/yaml-language-server"
}
},
"node_modules/yaml-language-server/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/yaml-language-server/node_modules/ajv-draft-04": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
"license": "MIT",
"peerDependencies": {
"ajv": "^8.5.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/yaml-language-server/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/yaml-language-server/node_modules/request-light": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz",
"integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==",
"license": "MIT"
},
"node_modules/yaml-language-server/node_modules/yaml": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
@@ -12637,7 +13260,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -12647,14 +13269,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/yargs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -12664,7 +13284,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -12679,7 +13298,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"

View File

@@ -3,7 +3,7 @@
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"dev": "node --env-file=.env node_modules/astro/astro.js dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
@@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/node": "^9.5.1",
"@astrojs/svelte": "^7.2.2",
"@tailwindcss/vite": "^4.1.17",

View File

@@ -0,0 +1,367 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { Button } from '$lib/components/ui/button';
import { Popover, PopoverTrigger, PopoverContent } from '$lib/components/ui/popover';
import { Input } from '$lib/components/ui/input';
import { client } from '$lib/graphql/client';
import {
GetReadingListsWithItemsDocument,
AddToReadingListDocument,
RemoveFromReadingListDocument,
CreateReadingListDocument,
type GetReadingListsWithItemsQuery
} from '$lib/graphql/__generated__/graphql';
import { isAuthenticated } from '$lib/auth/authStore';
import ListPlus from '@lucide/svelte/icons/list-plus';
import Plus from '@lucide/svelte/icons/plus';
import Check from '@lucide/svelte/icons/check';
import Loader2 from '@lucide/svelte/icons/loader-2';
interface Props {
novelId: number;
size?: 'default' | 'sm' | 'icon';
}
let { novelId, size = 'default' }: Props = $props();
type ReadingList = GetReadingListsWithItemsQuery['readingLists'][0];
// State
let popoverOpen = $state(false);
let readingLists: ReadingList[] = $state([]);
let fetching = $state(false);
let error: string | null = $state(null);
// Track which lists the novel is in (by list ID)
let novelInLists = new SvelteSet<number>();
// Track loading state for individual list toggles
let loadingListIds = new SvelteSet<number>();
// Quick-create state
let showQuickCreate = $state(false);
let newListName = $state('');
let creatingList = $state(false);
let createError: string | null = $state(null);
// Fetch reading lists when popover opens
$effect(() => {
if (popoverOpen && $isAuthenticated) {
fetchReadingLists();
}
});
// Reset quick-create form when popover closes
$effect(() => {
if (!popoverOpen) {
showQuickCreate = false;
newListName = '';
createError = null;
}
});
async function fetchReadingLists() {
fetching = true;
error = null;
try {
const result = await client.query(GetReadingListsWithItemsDocument, {}).toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data) {
readingLists = result.data.readingLists;
// Build the set of list IDs that contain this novel
novelInLists.clear();
for (const list of readingLists) {
if (list.items.some((item) => item.novelId === novelId)) {
novelInLists.add(list.id);
}
}
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load reading lists';
} finally {
fetching = false;
}
}
async function toggleNovelInList(listId: number) {
const isInList = novelInLists.has(listId);
loadingListIds.add(listId);
try {
if (isInList) {
// Remove from list
const result = await client
.mutation(RemoveFromReadingListDocument, {
input: { listId, novelId }
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.removeFromReadingList?.errors?.length) {
error = result.data.removeFromReadingList.errors[0]?.message ?? 'Failed to remove from list';
return;
}
if (result.data?.removeFromReadingList?.readingListPayload?.success) {
// Update local state
novelInLists.delete(listId);
// Update item count in list
readingLists = readingLists.map((list) =>
list.id === listId
? { ...list, itemCount: list.itemCount - 1, items: list.items.filter((i) => i.novelId !== novelId) }
: list
);
}
} else {
// Add to list
const result = await client
.mutation(AddToReadingListDocument, {
input: { readingListId: listId, novelId }
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.addToReadingList?.errors?.length) {
error = result.data.addToReadingList.errors[0]?.message ?? 'Failed to add to list';
return;
}
if (result.data?.addToReadingList?.readingListPayload?.success) {
// Update local state
novelInLists.add(listId);
// Update item count in list
readingLists = readingLists.map((list) =>
list.id === listId
? {
...list,
itemCount: list.itemCount + 1,
items: [...list.items, { novelId, order: list.itemCount, addedTime: new Date().toISOString() }]
}
: list
);
}
}
} catch (e) {
error = e instanceof Error ? e.message : 'An error occurred';
} finally {
loadingListIds.delete(listId);
}
}
async function createListAndAdd() {
if (!newListName.trim()) {
createError = 'Name is required';
return;
}
creatingList = true;
createError = null;
try {
const result = await client
.mutation(CreateReadingListDocument, {
input: {
name: newListName.trim(),
description: null
}
})
.toPromise();
if (result.error) {
createError = result.error.message;
return;
}
if (result.data?.createReadingList?.errors?.length) {
createError = result.data.createReadingList.errors[0]?.message ?? 'Failed to create list';
return;
}
const newList = result.data?.createReadingList?.readingListPayload?.readingList;
if (newList) {
// Now add the novel to the new list
const addResult = await client
.mutation(AddToReadingListDocument, {
input: { readingListId: newList.id, novelId }
})
.toPromise();
if (addResult.error) {
createError = addResult.error.message;
return;
}
if (addResult.data?.addToReadingList?.errors?.length) {
createError = addResult.data.addToReadingList.errors[0]?.message ?? 'Failed to add to list';
return;
}
// Add the new list to our local state
const fullNewList: ReadingList = {
...newList,
itemCount: 1,
items: [{ novelId, order: 0, addedTime: new Date().toISOString() }]
};
readingLists = [...readingLists, fullNewList];
novelInLists.add(newList.id);
// Reset quick-create form
showQuickCreate = false;
newListName = '';
}
} catch (e) {
createError = e instanceof Error ? e.message : 'An error occurred';
} finally {
creatingList = false;
}
}
function handleClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
createListAndAdd();
}
}
// Compute if novel is in any list for button state
let isInAnyList = $derived(novelInLists.size > 0);
</script>
{#if $isAuthenticated}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={handleClick}>
<Popover bind:open={popoverOpen}>
<PopoverTrigger asChild>
{#snippet child({ props })}
<Button
variant={isInAnyList ? 'default' : 'outline'}
{size}
class={size === 'icon' ? 'h-8 w-8' : 'gap-2'}
{...props}
>
<ListPlus class="h-4 w-4" />
{#if size !== 'icon'}
<span>{isInAnyList ? 'In Lists' : 'Add to List'}</span>
{/if}
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-80">
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">Add to Reading List</h4>
<p class="text-sm text-muted-foreground">
Select lists to add or remove this novel.
</p>
</div>
{#if fetching}
<div class="flex items-center justify-center py-4">
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
</div>
{:else if error}
<p class="text-sm text-destructive">{error}</p>
{:else if readingLists.length === 0 && !showQuickCreate}
<div class="text-center py-4">
<p class="text-sm text-muted-foreground mb-3">No reading lists yet</p>
<Button size="sm" variant="outline" onclick={() => (showQuickCreate = true)}>
<Plus class="h-4 w-4 mr-1" />
Create your first list
</Button>
</div>
{:else}
<!-- Reading lists -->
<div class="space-y-1 max-h-[200px] overflow-y-auto">
{#each readingLists as list (list.id)}
{@const isInList = novelInLists.has(list.id)}
{@const isLoading = loadingListIds.has(list.id)}
<button
type="button"
class="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left hover:bg-accent transition-colors disabled:opacity-50"
onclick={() => toggleNovelInList(list.id)}
disabled={isLoading}
>
<div
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border {isInList
? 'bg-primary border-primary'
: 'border-input'}"
>
{#if isLoading}
<Loader2 class="h-3 w-3 animate-spin text-primary-foreground" />
{:else if isInList}
<Check class="h-3 w-3 text-primary-foreground" />
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{list.name}</div>
<div class="text-xs text-muted-foreground">
{list.itemCount} {list.itemCount === 1 ? 'novel' : 'novels'}
</div>
</div>
</button>
{/each}
</div>
<!-- Quick-create section -->
{#if showQuickCreate}
<div class="border-t pt-3 space-y-2">
<div class="flex gap-2">
<Input
type="text"
placeholder="New list name"
bind:value={newListName}
disabled={creatingList}
onkeydown={handleKeyDown}
class="flex-1"
/>
<Button size="sm" onclick={createListAndAdd} disabled={creatingList || !newListName.trim()}>
{#if creatingList}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
Add
{/if}
</Button>
</div>
{#if createError}
<p class="text-xs text-destructive">{createError}</p>
{/if}
</div>
{:else}
<div class="border-t pt-3">
<Button
size="sm"
variant="ghost"
class="w-full justify-start"
onclick={() => (showQuickCreate = true)}
>
<Plus class="h-4 w-4 mr-2" />
Create new list
</Button>
</div>
{/if}
{/if}
</div>
</PopoverContent>
</Popover>
</div>
{/if}

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Popover, PopoverTrigger, PopoverContent } from '$lib/components/ui/popover';
import { Textarea } from '$lib/components/ui/textarea';
import { client } from '$lib/graphql/client';
import { UpsertBookmarkDocument, RemoveBookmarkDocument } from '$lib/graphql/__generated__/graphql';
import Bookmark from '@lucide/svelte/icons/bookmark';
import BookmarkCheck from '@lucide/svelte/icons/bookmark-check';
interface Props {
novelId: number;
chapterId: number;
isBookmarked?: boolean;
bookmarkDescription?: string | null;
size?: 'default' | 'sm' | 'icon';
onBookmarkChange?: (isBookmarked: boolean, description?: string | null) => void;
}
let {
novelId,
chapterId,
isBookmarked = false,
bookmarkDescription = null,
size = 'icon',
onBookmarkChange
}: Props = $props();
// Bookmark state
let popoverOpen = $state(false);
let description = $state(bookmarkDescription ?? '');
let saving = $state(false);
let removing = $state(false);
let error: string | null = $state(null);
// Reset description when popover opens
$effect(() => {
if (popoverOpen) {
description = bookmarkDescription ?? '';
error = null;
}
});
async function saveBookmark() {
saving = true;
error = null;
try {
const result = await client
.mutation(UpsertBookmarkDocument, {
input: {
chapterId,
novelId,
description: description.trim() || null
}
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.upsertBookmark?.errors?.length) {
error = result.data.upsertBookmark.errors[0]?.message ?? 'Failed to save bookmark';
return;
}
if (result.data?.upsertBookmark?.bookmarkPayload?.success) {
popoverOpen = false;
onBookmarkChange?.(true, description.trim() || null);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save bookmark';
} finally {
saving = false;
}
}
async function removeBookmark() {
removing = true;
error = null;
try {
const result = await client
.mutation(RemoveBookmarkDocument, {
input: { chapterId }
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.removeBookmark?.errors?.length) {
error = result.data.removeBookmark.errors[0]?.message ?? 'Failed to remove bookmark';
return;
}
if (result.data?.removeBookmark?.bookmarkPayload?.success) {
popoverOpen = false;
description = '';
onBookmarkChange?.(false, null);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove bookmark';
} finally {
removing = false;
}
}
function handleClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={handleClick}>
<Popover bind:open={popoverOpen}>
<PopoverTrigger asChild>
{#snippet child({ props })}
<Button
variant={isBookmarked ? 'default' : 'ghost'}
{size}
class={size === 'icon' ? 'h-8 w-8' : 'gap-2'}
{...props}
>
{#if isBookmarked}
<BookmarkCheck class="h-4 w-4" />
{:else}
<Bookmark class="h-4 w-4" />
{/if}
{#if size !== 'icon'}
<span>{isBookmarked ? 'Bookmarked' : 'Bookmark'}</span>
{/if}
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-80">
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">
{isBookmarked ? 'Edit bookmark' : 'Bookmark this chapter'}
</h4>
<p class="text-sm text-muted-foreground">
{isBookmarked ? 'Update your note or remove the bookmark.' : 'Add an optional note to remember why you bookmarked this.'}
</p>
</div>
<Textarea
bind:value={description}
placeholder="Add a note..."
class="min-h-[80px] resize-none"
/>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<div class="flex justify-end gap-2">
{#if isBookmarked}
<Button
variant="destructive"
size="sm"
onclick={removeBookmark}
disabled={removing || saving}
>
{removing ? 'Removing...' : 'Remove'}
</Button>
{/if}
<Button
size="sm"
onclick={saveBookmark}
disabled={saving || removing}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>

View File

@@ -1,29 +1,131 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Popover, PopoverTrigger, PopoverContent } from '$lib/components/ui/popover';
import { Textarea } from '$lib/components/ui/textarea';
import { client } from '$lib/graphql/client';
import { UpsertBookmarkDocument, RemoveBookmarkDocument } from '$lib/graphql/__generated__/graphql';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import List from '@lucide/svelte/icons/list';
import Bookmark from '@lucide/svelte/icons/bookmark';
import BookmarkCheck from '@lucide/svelte/icons/bookmark-check';
interface Props {
novelId: string;
chapterId?: number;
prevChapterVolumeOrder: number | null | undefined;
prevChapterOrder: number | null | undefined;
nextChapterVolumeOrder: number | null | undefined;
nextChapterOrder: number | null | undefined;
showKeyboardHints?: boolean;
isBookmarked?: boolean;
bookmarkDescription?: string | null;
onBookmarkChange?: (isBookmarked: boolean, description?: string | null) => void;
}
let {
novelId,
chapterId,
prevChapterVolumeOrder,
prevChapterOrder,
nextChapterVolumeOrder,
nextChapterOrder,
showKeyboardHints = true
showKeyboardHints = true,
isBookmarked = false,
bookmarkDescription = null,
onBookmarkChange
}: Props = $props();
const hasPrev = $derived(prevChapterOrder != null && prevChapterVolumeOrder != null);
const hasNext = $derived(nextChapterOrder != null && nextChapterVolumeOrder != null);
// Bookmark state
let popoverOpen = $state(false);
let description = $state(bookmarkDescription ?? '');
let saving = $state(false);
let removing = $state(false);
let error: string | null = $state(null);
// Reset description when popover opens
$effect(() => {
if (popoverOpen) {
description = bookmarkDescription ?? '';
error = null;
}
});
async function saveBookmark() {
if (!chapterId) return;
saving = true;
error = null;
try {
const result = await client
.mutation(UpsertBookmarkDocument, {
input: {
chapterId,
novelId: parseInt(novelId, 10),
description: description.trim() || null
}
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.upsertBookmark?.errors?.length) {
error = result.data.upsertBookmark.errors[0]?.message ?? 'Failed to save bookmark';
return;
}
if (result.data?.upsertBookmark?.bookmarkPayload?.success) {
popoverOpen = false;
onBookmarkChange?.(true, description.trim() || null);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save bookmark';
} finally {
saving = false;
}
}
async function removeBookmark() {
if (!chapterId) return;
removing = true;
error = null;
try {
const result = await client
.mutation(RemoveBookmarkDocument, {
input: { chapterId }
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.removeBookmark?.errors?.length) {
error = result.data.removeBookmark.errors[0]?.message ?? 'Failed to remove bookmark';
return;
}
if (result.data?.removeBookmark?.bookmarkPayload?.success) {
popoverOpen = false;
description = '';
onBookmarkChange?.(false, null);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove bookmark';
} finally {
removing = false;
}
}
</script>
<div class="flex flex-col gap-2">
@@ -38,10 +140,72 @@
<span class="hidden sm:inline">Previous</span>
</Button>
<Button variant="outline" href="/novels/{novelId}" class="gap-2">
<List class="h-4 w-4" />
<span class="hidden sm:inline">Contents</span>
</Button>
<div class="flex items-center gap-2">
<Button variant="outline" href="/novels/{novelId}" class="gap-2">
<List class="h-4 w-4" />
<span class="hidden sm:inline">Contents</span>
</Button>
{#if chapterId}
<Popover bind:open={popoverOpen}>
<PopoverTrigger asChild>
{#snippet child({ props })}
<Button
variant={isBookmarked ? 'default' : 'outline'}
class="gap-2"
{...props}
>
{#if isBookmarked}
<BookmarkCheck class="h-4 w-4" />
{:else}
<Bookmark class="h-4 w-4" />
{/if}
<span class="hidden sm:inline">{isBookmarked ? 'Bookmarked' : 'Bookmark'}</span>
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-80">
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">
{isBookmarked ? 'Edit bookmark' : 'Bookmark this chapter'}
</h4>
<p class="text-sm text-muted-foreground">
{isBookmarked ? 'Update your note or remove the bookmark.' : 'Add an optional note to remember why you bookmarked this.'}
</p>
</div>
<Textarea
bind:value={description}
placeholder="Add a note..."
class="min-h-[80px] resize-none"
/>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<div class="flex justify-end gap-2">
{#if isBookmarked}
<Button
variant="destructive"
size="sm"
onclick={removeBookmark}
disabled={removing || saving}
>
{removing ? 'Removing...' : 'Remove'}
</Button>
{/if}
<Button
size="sm"
onclick={saveBookmark}
disabled={saving || removing}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{/if}
</div>
<Button
variant="outline"

View File

@@ -1,13 +1,14 @@
<script lang="ts" module>
import type { GetChapterQuery } from '$lib/graphql/__generated__/graphql';
import type { GetChapterQuery, GetBookmarksQuery } from '$lib/graphql/__generated__/graphql';
export type ChapterData = NonNullable<GetChapterQuery['chapter']>;
export type BookmarkData = GetBookmarksQuery['bookmarks'][number];
</script>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { client } from '$lib/graphql/client';
import { GetChapterDocument } from '$lib/graphql/__generated__/graphql';
import { GetChapterDocument, GetBookmarksDocument } from '$lib/graphql/__generated__/graphql';
import { Card, CardContent } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import ChapterNavigation from './ChapterNavigation.svelte';
@@ -18,16 +19,30 @@
novelId?: string;
volumeOrder?: string;
chapterNumber?: string;
initialChapter?: ChapterData | null;
initialAuthFailed?: boolean;
initialError?: string | null;
}
let { novelId, volumeOrder, chapterNumber }: Props = $props();
let {
novelId,
volumeOrder,
chapterNumber,
initialChapter = null,
initialAuthFailed = false,
initialError = null
}: Props = $props();
// State
let chapter: ChapterData | null = $state(null);
let fetching = $state(true);
let error: string | null = $state(null);
// State - initialize from server data if available
let chapter: ChapterData | null = $state(initialChapter);
let fetching = $state(!initialChapter && !initialError && !initialAuthFailed);
let error: string | null = $state(initialError);
let scrollProgress = $state(0);
// Bookmark state
let isBookmarked = $state(false);
let bookmarkDescription: string | null = $state(null);
// Derived values
const sanitizedBody = $derived(chapter?.body ? sanitizeChapterHtml(chapter.body) : '');
@@ -78,6 +93,8 @@
chapter = result.data.chapter;
// Update the page title with chapter info
document.title = `${chapter.novelName} - ${chapter.order}`;
// Fetch bookmark status
await fetchBookmarks();
} else {
error = 'Chapter not found';
}
@@ -88,8 +105,43 @@
}
}
onMount(() => {
fetchChapter();
async function fetchBookmarks() {
if (!novelId || !chapter) return;
try {
const result = await client
.query(GetBookmarksDocument, { novelId: parseInt(novelId, 10) })
.toPromise();
if (result.data?.bookmarks) {
const bookmark = result.data.bookmarks.find((b) => b.chapterId === chapter!.id);
if (bookmark) {
isBookmarked = true;
bookmarkDescription = bookmark.description ?? null;
} else {
isBookmarked = false;
bookmarkDescription = null;
}
}
} catch {
// Silently fail - bookmark status is non-critical
}
}
function handleBookmarkChange(newIsBookmarked: boolean, newDescription?: string | null) {
isBookmarked = newIsBookmarked;
bookmarkDescription = newDescription ?? null;
}
onMount(async () => {
if (initialAuthFailed || (!initialChapter && !initialError)) {
// Fetch client-side: either auth recovery or fallback
await fetchChapter();
} else if (chapter) {
// Server provided data, just fetch bookmarks
await fetchBookmarks();
}
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('keydown', handleKeydown);
});
@@ -139,10 +191,14 @@
<!-- Navigation (top) -->
<ChapterNavigation
novelId={novelId ?? ''}
chapterId={chapter.id}
prevChapterVolumeOrder={chapter.prevChapterVolumeOrder}
prevChapterOrder={chapter.prevChapterOrder}
nextChapterVolumeOrder={chapter.nextChapterVolumeOrder}
nextChapterOrder={chapter.nextChapterOrder}
{isBookmarked}
{bookmarkDescription}
onBookmarkChange={handleBookmarkChange}
/>
<!-- Chapter Header -->
@@ -173,11 +229,15 @@
<!-- Navigation (bottom) -->
<ChapterNavigation
novelId={novelId ?? ''}
chapterId={chapter.id}
prevChapterVolumeOrder={chapter.prevChapterVolumeOrder}
prevChapterOrder={chapter.prevChapterOrder}
nextChapterVolumeOrder={chapter.nextChapterVolumeOrder}
nextChapterOrder={chapter.nextChapterOrder}
showKeyboardHints={false}
{isBookmarked}
{bookmarkDescription}
onBookmarkChange={handleBookmarkChange}
/>
{/if}
</div>

View File

@@ -19,11 +19,10 @@
description="Explore and read archived novels."
/>
<NavigationCard
href="/lists"
href="/reading-lists"
icon={List}
title="Reading Lists"
description="Organize stories into custom collections."
disabled
/>
<NavigationCard
href="/recommendations"

View File

@@ -2,6 +2,7 @@
import * as NavigationMenu from '$lib/components/ui/navigation-menu';
import AuthenticationDisplay from './AuthenticationDisplay.svelte';
import SearchBar from './SearchBar.svelte';
import { isAuthenticated } from '$lib/auth/authStore';
let pathname = $state(typeof window !== 'undefined' ? window.location.pathname : '/');
@@ -24,6 +25,11 @@
<NavigationMenu.Item>
<NavigationMenu.Link href="/novels" active={isActive('/novels')}>Novels</NavigationMenu.Link>
</NavigationMenu.Item>
{#if $isAuthenticated}
<NavigationMenu.Item>
<NavigationMenu.Link href="/reading-lists" active={isActive('/reading-lists')}>Reading Lists</NavigationMenu.Link>
</NavigationMenu.Item>
{/if}
</NavigationMenu.List>
</NavigationMenu.Root>
<div class="flex-1"></div>

View File

@@ -1,8 +1,10 @@
<script lang="ts" module>
import type { NovelQuery, NovelStatus, Language } from '$lib/graphql/__generated__/graphql';
import type { NovelQuery, NovelStatus, Language, GetBookmarksQuery } from '$lib/graphql/__generated__/graphql';
import { TagType } from '$lib/graphql/__generated__/graphql';
import { SystemTags } from '$lib/constants/systemTags';
export type BookmarkData = GetBookmarksQuery['bookmarks'][number];
export type NovelNode = NonNullable<NonNullable<NovelQuery['novels']>['nodes']>[number];
const statusColors: Record<NovelStatus, string> = {
@@ -32,7 +34,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { client } from '$lib/graphql/client';
import { NovelDocument, ImportNovelDocument, DeleteNovelDocument } from '$lib/graphql/__generated__/graphql';
import { NovelDocument, ImportNovelDocument, DeleteNovelDocument, GetBookmarksDocument } from '$lib/graphql/__generated__/graphql';
import { isAuthenticated } from '$lib/auth/authStore';
import { Card, CardContent, CardHeader } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
@@ -52,6 +54,8 @@
} from '$lib/components/ui/tooltip';
import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time';
import { sanitizeHtml } from '$lib/utils/sanitize';
import ChapterBookmarkButton from './ChapterBookmarkButton.svelte';
import AddToReadingListButton from './AddToReadingListButton.svelte';
// Direct imports for faster builds
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ExternalLink from '@lucide/svelte/icons/external-link';
@@ -98,6 +102,11 @@
let activeTab = $state('chapters');
let galleryLoaded = $state(false);
// Bookmarks state
let bookmarks: BookmarkData[] = $state([]);
let bookmarksLoaded = $state(false);
let bookmarksFetching = $state(false);
const DESCRIPTION_PREVIEW_LENGTH = 300;
// Derived values
@@ -128,6 +137,41 @@
const isSingleVolume = $derived(sortedVolumes.length === 1);
// Chapter lookup for bookmarks (maps chapterId to chapter details)
const chapterLookup = $derived(
new Map(
sortedVolumes.flatMap((v) =>
v.chapters.map((c) => [c.id, { ...c, volumeOrder: v.order }])
)
)
);
// Bookmark lookup by chapterId for quick access in chapter list
const bookmarkLookup = $derived(
new Map(bookmarks.map((b) => [b.chapterId, b]))
);
function handleChapterBookmarkChange(chapterId: number, isBookmarked: boolean, description?: string | null) {
if (isBookmarked) {
// Add or update bookmark in local state
const existingIndex = bookmarks.findIndex((b) => b.chapterId === chapterId);
const newBookmark = {
id: existingIndex >= 0 ? bookmarks[existingIndex].id : -1, // temp id
chapterId,
description: description ?? null,
createdTime: new Date().toISOString()
};
if (existingIndex >= 0) {
bookmarks[existingIndex] = newBookmark;
} else {
bookmarks = [...bookmarks, newBookmark];
}
} else {
// Remove bookmark from local state
bookmarks = bookmarks.filter((b) => b.chapterId !== chapterId);
}
}
const chapterCount = $derived(
sortedVolumes.reduce((sum, v) => sum + v.chapters.length, 0)
);
@@ -184,6 +228,34 @@
}
});
// Load bookmarks when novel is loaded (for count display)
$effect(() => {
if (novel && !bookmarksLoaded && novelId) {
fetchBookmarks();
}
});
async function fetchBookmarks() {
if (!novelId || bookmarksFetching) return;
bookmarksFetching = true;
try {
const result = await client
.query(GetBookmarksDocument, { novelId: parseInt(novelId, 10) })
.toPromise();
if (result.data?.bookmarks) {
bookmarks = result.data.bookmarks;
}
} catch {
// Silently fail - bookmarks are non-critical
} finally {
bookmarksFetching = false;
bookmarksLoaded = true;
}
}
// Image viewer functions
function openImageViewer(index: number) {
viewerIndex = index;
@@ -420,6 +492,7 @@
<Trash2 class="h-3 w-3" />
Delete
</Button>
<AddToReadingListButton novelId={novel.id} />
{/if}
{#if refreshSuccess}
<Badge variant="outline" class="bg-green-500/10 text-green-600 border-green-500/30">
@@ -528,10 +601,9 @@
</TabsTrigger>
<TabsTrigger
value="bookmarks"
disabled
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all"
>
Bookmarks
Bookmarks{bookmarksLoaded ? ` (${bookmarks.length})` : ''}
</TabsTrigger>
</TabsList>
</CardHeader>
@@ -548,24 +620,36 @@
<div class="max-h-96 overflow-y-auto -mx-2">
{#each singleVolumeChapters as chapter (chapter.id)}
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
<a
href="/novels/{novelId}/volumes/{sortedVolumes[0]?.order}/chapters/{chapter.order}"
class="flex items-center justify-between px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group"
>
<div class="flex items-center gap-3 min-w-0">
{@const chapterBookmark = bookmarkLookup.get(chapter.id)}
<div class="flex items-center px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group">
<a
href="/novels/{novelId}/volumes/{sortedVolumes[0]?.order}/chapters/{chapter.order}"
class="flex items-center gap-3 min-w-0 flex-1"
>
<span class="text-muted-foreground text-sm font-medium shrink-0 w-14">
Ch. {chapter.order}
</span>
<span class="text-sm truncate group-hover:text-primary transition-colors">
{chapter.name}
</span>
</a>
<div class="flex items-center gap-2 shrink-0 ml-2">
{#if chapterDate}
<span class="text-xs text-muted-foreground/70">
{formatRelativeTime(chapterDate)}
</span>
{/if}
{#if novelId}
<ChapterBookmarkButton
novelId={parseInt(novelId, 10)}
chapterId={chapter.id}
isBookmarked={!!chapterBookmark}
bookmarkDescription={chapterBookmark?.description}
onBookmarkChange={(isBookmarked, description) => handleChapterBookmarkChange(chapter.id, isBookmarked, description)}
/>
{/if}
</div>
{#if chapterDate}
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
{formatRelativeTime(chapterDate)}
</span>
{/if}
</a>
</div>
{/each}
</div>
{:else}
@@ -587,24 +671,36 @@
<div class="space-y-0.5">
{#each volumeChapters as chapter (chapter.id)}
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
<a
href="/novels/{novelId}/volumes/{volume.order}/chapters/{chapter.order}"
class="flex items-center justify-between px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group"
>
<div class="flex items-center gap-3 min-w-0">
{@const chapterBookmark = bookmarkLookup.get(chapter.id)}
<div class="flex items-center px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group">
<a
href="/novels/{novelId}/volumes/{volume.order}/chapters/{chapter.order}"
class="flex items-center gap-3 min-w-0 flex-1"
>
<span class="text-muted-foreground text-sm font-medium shrink-0 w-14">
Ch. {chapter.order}
</span>
<span class="text-sm truncate group-hover:text-primary transition-colors">
{chapter.name}
</span>
</a>
<div class="flex items-center gap-2 shrink-0 ml-2">
{#if chapterDate}
<span class="text-xs text-muted-foreground/70">
{formatRelativeTime(chapterDate)}
</span>
{/if}
{#if novelId}
<ChapterBookmarkButton
novelId={parseInt(novelId, 10)}
chapterId={chapter.id}
isBookmarked={!!chapterBookmark}
bookmarkDescription={chapterBookmark?.description}
onBookmarkChange={(isBookmarked, description) => handleChapterBookmarkChange(chapter.id, isBookmarked, description)}
/>
{/if}
</div>
{#if chapterDate}
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
{formatRelativeTime(chapterDate)}
</span>
{/if}
</a>
</div>
{/each}
</div>
</AccordionContent>
@@ -646,9 +742,50 @@
</TabsContent>
<TabsContent value="bookmarks" class="mt-0">
<p class="text-muted-foreground text-sm py-8 text-center">
Bookmarks coming soon.
</p>
{#if bookmarksFetching}
<div class="flex items-center justify-center py-8">
<div
class="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
aria-label="Loading bookmarks"
></div>
</div>
{:else if bookmarks.length === 0}
<p class="text-muted-foreground text-sm py-8 text-center">
No bookmarks yet. Add bookmarks while reading chapters.
</p>
{:else}
<div class="max-h-96 overflow-y-auto -mx-2">
{#each bookmarks as bookmark (bookmark.id)}
{@const chapter = chapterLookup.get(bookmark.chapterId)}
{#if chapter}
{@const bookmarkDate = new Date(bookmark.createdTime)}
<a
href="/novels/{novelId}/volumes/{chapter.volumeOrder}/chapters/{chapter.order}"
class="flex items-center justify-between px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3">
<span class="text-muted-foreground text-sm font-medium shrink-0 w-14">
Ch. {chapter.order}
</span>
<span class="text-sm truncate group-hover:text-primary transition-colors">
{chapter.name}
</span>
</div>
{#if bookmark.description}
<p class="text-xs text-muted-foreground/70 mt-1 ml-[4.25rem] truncate">
{bookmark.description}
</p>
{/if}
</div>
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
{formatRelativeTime(bookmarkDate)}
</span>
</a>
{/if}
{/each}
</div>
{/if}
</TabsContent>
</CardContent>
</Tabs>

View File

@@ -0,0 +1,393 @@
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import { onMount } from 'svelte';
import { client } from '$lib/graphql/client';
import {
GetReadingListDocument,
NovelsDocument,
RemoveFromReadingListDocument,
ReorderReadingListItemDocument,
type GetReadingListQuery,
type NovelsQuery
} from '$lib/graphql/__generated__/graphql';
import { isAuthenticated, login } from '$lib/auth/authStore';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import Trash2 from '@lucide/svelte/icons/trash-2';
import BookOpen from '@lucide/svelte/icons/book-open';
import LogIn from '@lucide/svelte/icons/log-in';
interface Props {
listId: string;
}
let { listId }: Props = $props();
type ReadingList = NonNullable<GetReadingListQuery['readingList']>;
type ReadingListItem = ReadingList['items'][number];
type NovelNode = NonNullable<NonNullable<NovelsQuery['novels']>['edges']>[number]['node'];
// State
let readingList: ReadingList | null = $state(null);
let novels = new SvelteMap<number, NovelNode>();
let fetching = $state(true);
let error: string | null = $state(null);
// Operation state
let reordering = $state(false);
let removing: number | null = $state(null);
let operationError: string | null = $state(null);
// Derived: sorted items by order
const sortedItems = $derived(
readingList?.items ? [...readingList.items].sort((a, b) => a.order - b.order) : []
);
async function fetchReadingList() {
fetching = true;
error = null;
try {
const id = parseInt(listId, 10);
if (isNaN(id)) {
error = 'Invalid reading list ID';
fetching = false;
return;
}
const result = await client.query(GetReadingListDocument, { id }).toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (!result.data?.readingList) {
error = 'Reading list not found';
return;
}
readingList = result.data.readingList;
// Fetch novel details for all items
if (readingList.items.length > 0) {
await fetchNovels(readingList.items.map((item) => item.novelId));
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
}
}
async function fetchNovels(novelIds: number[]) {
if (novelIds.length === 0) return;
try {
const result = await client
.query(NovelsDocument, {
first: novelIds.length,
where: { id: { in: novelIds } }
})
.toPromise();
if (result.data?.novels?.edges) {
for (const edge of result.data.novels.edges) {
novels.set(edge.node.id, edge.node);
}
}
} catch {
// Non-critical: novels just won't show extra details
}
}
async function moveItem(item: ReadingListItem, direction: 'up' | 'down') {
if (!readingList || reordering) return;
const currentIndex = sortedItems.findIndex((i) => i.novelId === item.novelId);
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= sortedItems.length) return;
const targetItem = sortedItems[targetIndex];
const newOrder = targetItem.order;
reordering = true;
operationError = null;
try {
const result = await client
.mutation(ReorderReadingListItemDocument, {
input: {
readingListId: readingList.id,
novelId: item.novelId,
newOrder
}
})
.toPromise();
if (result.error) {
operationError = result.error.message;
return;
}
if (result.data?.reorderReadingListItem?.errors?.length) {
operationError = result.data.reorderReadingListItem.errors[0]?.message ?? 'Failed to reorder';
return;
}
// Refresh the list to get updated order
await fetchReadingList();
} catch (e) {
operationError = e instanceof Error ? e.message : 'Failed to reorder';
} finally {
reordering = false;
}
}
async function removeItem(novelId: number) {
if (!readingList || removing !== null) return;
removing = novelId;
operationError = null;
try {
const result = await client
.mutation(RemoveFromReadingListDocument, {
input: {
listId: readingList.id,
novelId
}
})
.toPromise();
if (result.error) {
operationError = result.error.message;
return;
}
if (result.data?.removeFromReadingList?.errors?.length) {
operationError = result.data.removeFromReadingList.errors[0]?.message ?? 'Failed to remove';
return;
}
// Update local state
if (readingList) {
readingList = {
...readingList,
items: readingList.items.filter((item) => item.novelId !== novelId),
itemCount: readingList.itemCount - 1
};
}
} catch (e) {
operationError = e instanceof Error ? e.message : 'Failed to remove';
} finally {
removing = null;
}
}
onMount(() => {
if ($isAuthenticated) {
fetchReadingList();
} else {
fetching = false;
}
});
// Re-fetch when auth changes
$effect(() => {
if ($isAuthenticated) {
fetchReadingList();
} else {
readingList = null;
novels.clear();
fetching = false;
}
});
</script>
<div class="space-y-6">
<!-- Back Navigation -->
<Button variant="ghost" href="/reading-lists" class="gap-2 -ml-2">
<ArrowLeft class="h-4 w-4" />
Back to Reading Lists
</Button>
{#if !$isAuthenticated}
<!-- Auth gate - sign in prompt -->
<Card>
<CardContent class="py-12">
<div class="text-center space-y-4">
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<h3 class="text-lg font-medium">Sign in to view Reading Lists</h3>
<p class="text-muted-foreground">
Sign in to view and manage your reading lists.
</p>
</div>
<Button onclick={login}>
<LogIn class="mr-2 h-4 w-4" />
Sign In
</Button>
</div>
</CardContent>
</Card>
{:else if fetching}
<!-- Loading state -->
<Card>
<CardContent>
<div class="flex items-center justify-center py-12">
<div
class="border-primary h-10 w-10 animate-spin rounded-full border-2 border-t-transparent"
aria-label="Loading reading list"
></div>
</div>
</CardContent>
</Card>
{:else if error}
<!-- Error state -->
<Card class="border-destructive/40 bg-destructive/5">
<CardContent class="py-8">
<div class="text-center">
<p class="text-destructive text-lg font-medium">
{error === 'Reading list not found' ? 'Reading List Not Found' : 'Error Loading Reading List'}
</p>
<p class="text-muted-foreground mt-2 text-sm">{error}</p>
<Button variant="outline" onclick={fetchReadingList} class="mt-4">
Try Again
</Button>
</div>
</CardContent>
</Card>
{:else if readingList}
<!-- Header -->
<Card>
<CardHeader>
<CardTitle class="text-2xl">{readingList.name}</CardTitle>
{#if readingList.description}
<CardDescription class="text-base">{readingList.description}</CardDescription>
{/if}
<p class="text-sm text-muted-foreground">
{readingList.itemCount} {readingList.itemCount === 1 ? 'novel' : 'novels'}
</p>
</CardHeader>
</Card>
<!-- Operation error -->
{#if operationError}
<Card class="border-destructive/40 bg-destructive/5">
<CardContent class="py-4">
<p class="text-destructive text-sm">{operationError}</p>
</CardContent>
</Card>
{/if}
<!-- Novels list -->
{#if sortedItems.length === 0}
<!-- Empty state -->
<Card>
<CardContent class="py-12">
<div class="text-center space-y-4">
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<h3 class="text-lg font-medium">No novels in this list</h3>
<p class="text-muted-foreground">
Add novels to this reading list from a novel's detail page.
</p>
</div>
<Button href="/novels" variant="outline">
Browse Novels
</Button>
</div>
</CardContent>
</Card>
{:else}
<Card>
<CardContent class="py-4">
<div class="space-y-2">
{#each sortedItems as item, index (item.novelId)}
{@const novel = novels.get(item.novelId)}
<div
class="flex items-center gap-4 p-3 rounded-lg hover:bg-muted/50 transition-colors {removing === item.novelId || reordering ? 'opacity-50' : ''}"
>
<!-- Cover image -->
<a href={`/novels/${item.novelId}`} class="shrink-0">
{#if novel?.coverImage?.newPath}
<div class="w-16 h-20 overflow-hidden rounded-md bg-muted/50">
<img
src={novel.coverImage.newPath}
alt={novel?.name ?? 'Novel cover'}
class="h-full w-full object-cover"
/>
</div>
{:else}
<div class="w-16 h-20 rounded-md bg-muted/50 flex items-center justify-center">
<BookOpen class="h-6 w-6 text-muted-foreground/50" />
</div>
{/if}
</a>
<!-- Novel info -->
<div class="flex-1 min-w-0">
<a
href={`/novels/${item.novelId}`}
class="font-medium hover:text-primary transition-colors line-clamp-1"
>
{novel?.name ?? `Novel #${item.novelId}`}
</a>
{#if novel?.description}
<p class="text-sm text-muted-foreground line-clamp-2 mt-1">
{novel.description}
</p>
{/if}
</div>
<!-- Action buttons -->
<div class="flex items-center gap-1 shrink-0">
<!-- Move up -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
disabled={index === 0 || reordering || removing !== null}
onclick={() => moveItem(item, 'up')}
>
<ArrowUp class="h-4 w-4" />
<span class="sr-only">Move up</span>
</Button>
<!-- Move down -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
disabled={index === sortedItems.length - 1 || reordering || removing !== null}
onclick={() => moveItem(item, 'down')}
>
<ArrowDown class="h-4 w-4" />
<span class="sr-only">Move down</span>
</Button>
<!-- Remove -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
disabled={reordering || removing !== null}
onclick={() => removeItem(item.novelId)}
>
<Trash2 class="h-4 w-4" />
<span class="sr-only">Remove from list</span>
</Button>
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,432 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Dialog as DialogPrimitive } from 'bits-ui';
import { client } from '$lib/graphql/client';
import {
GetReadingListsDocument,
CreateReadingListDocument,
UpdateReadingListDocument,
DeleteReadingListDocument,
type GetReadingListsQuery
} from '$lib/graphql/__generated__/graphql';
import { isAuthenticated, login } from '$lib/auth/authStore';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '$lib/components/ui/card';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import BookOpen from '@lucide/svelte/icons/book-open';
import X from '@lucide/svelte/icons/x';
import LogIn from '@lucide/svelte/icons/log-in';
type ReadingList = GetReadingListsQuery['readingLists'][0];
// State
let readingLists: ReadingList[] = $state([]);
let fetching = $state(true);
let error: string | null = $state(null);
// Dialog state
let dialogOpen = $state(false);
let dialogMode: 'create' | 'edit' = $state('create');
let editingList: ReadingList | null = $state(null);
// Form state
let formName = $state('');
let formDescription = $state('');
let formSubmitting = $state(false);
let formError: string | null = $state(null);
// Delete confirmation state
let deleteDialogOpen = $state(false);
let deletingList: ReadingList | null = $state(null);
let deleteSubmitting = $state(false);
let deleteError: string | null = $state(null);
// Reset form when dialog opens/closes
$effect(() => {
if (dialogOpen) {
if (dialogMode === 'edit' && editingList) {
formName = editingList.name;
formDescription = editingList.description ?? '';
} else {
formName = '';
formDescription = '';
}
formError = null;
}
});
async function fetchReadingLists(skipCache = false) {
fetching = true;
error = null;
try {
const result = await client.query(GetReadingListsDocument, {}, { requestPolicy: skipCache ? 'network-only' : 'cache-first' }).toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data) {
readingLists = result.data.readingLists;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
}
}
function openCreateDialog() {
dialogMode = 'create';
editingList = null;
dialogOpen = true;
}
function openEditDialog(list: ReadingList) {
dialogMode = 'edit';
editingList = list;
dialogOpen = true;
}
function openDeleteDialog(list: ReadingList) {
deletingList = list;
deleteError = null;
deleteDialogOpen = true;
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!formName.trim()) {
formError = 'Name is required';
return;
}
formSubmitting = true;
formError = null;
try {
if (dialogMode === 'create') {
const result = await client
.mutation(CreateReadingListDocument, {
input: {
name: formName.trim(),
description: formDescription.trim() || null
}
})
.toPromise();
if (result.error) {
formError = result.error.message;
return;
}
if (result.data?.createReadingList?.errors?.length) {
formError = result.data.createReadingList.errors[0]?.message ?? 'Failed to create reading list';
return;
}
if (result.data?.createReadingList?.readingListPayload?.readingList) {
dialogOpen = false;
await fetchReadingLists(true);
}
} else if (dialogMode === 'edit' && editingList) {
const result = await client
.mutation(UpdateReadingListDocument, {
input: {
id: editingList.id,
name: formName.trim(),
description: formDescription.trim() || null
}
})
.toPromise();
if (result.error) {
formError = result.error.message;
return;
}
if (result.data?.updateReadingList?.errors?.length) {
formError = result.data.updateReadingList.errors[0]?.message ?? 'Failed to update reading list';
return;
}
if (result.data?.updateReadingList?.readingListPayload?.readingList) {
dialogOpen = false;
await fetchReadingLists(true);
}
}
} catch (e) {
formError = e instanceof Error ? e.message : 'An error occurred';
} finally {
formSubmitting = false;
}
}
async function handleDelete() {
if (!deletingList) return;
deleteSubmitting = true;
deleteError = null;
try {
const result = await client
.mutation(DeleteReadingListDocument, {
input: { id: deletingList.id }
})
.toPromise();
if (result.error) {
deleteError = result.error.message;
return;
}
if (result.data?.deleteReadingList?.errors?.length) {
deleteError = result.data.deleteReadingList.errors[0]?.message ?? 'Failed to delete reading list';
return;
}
if (result.data?.deleteReadingList?.success) {
deleteDialogOpen = false;
deletingList = null;
await fetchReadingLists(true);
}
} catch (e) {
deleteError = e instanceof Error ? e.message : 'An error occurred';
} finally {
deleteSubmitting = false;
}
}
onMount(() => {
if ($isAuthenticated) {
fetchReadingLists();
} else {
fetching = false;
}
});
// Re-fetch when auth changes
$effect(() => {
if ($isAuthenticated) {
fetchReadingLists();
} else {
readingLists = [];
fetching = false;
}
});
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">Reading Lists</h1>
<p class="text-muted-foreground">Organize your novels into collections</p>
</div>
{#if $isAuthenticated}
<Button onclick={openCreateDialog}>
<Plus class="mr-2 h-4 w-4" />
New List
</Button>
{/if}
</div>
{#if !$isAuthenticated}
<!-- Auth gate - sign in prompt -->
<Card>
<CardContent class="py-12">
<div class="text-center space-y-4">
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<h3 class="text-lg font-medium">Sign in to use Reading Lists</h3>
<p class="text-muted-foreground">
Create and manage your personal reading lists to organize novels.
</p>
</div>
<Button onclick={login}>
<LogIn class="mr-2 h-4 w-4" />
Sign In
</Button>
</div>
</CardContent>
</Card>
{:else if fetching}
<!-- Loading state -->
<div class="flex items-center justify-center py-12">
<div class="text-muted-foreground">Loading your reading lists...</div>
</div>
{:else if error}
<!-- Error state -->
<Card>
<CardContent class="py-6">
<p class="text-destructive">{error}</p>
<Button class="mt-4" onclick={fetchReadingLists}>Try Again</Button>
</CardContent>
</Card>
{:else if readingLists.length === 0}
<!-- Empty state -->
<Card>
<CardContent class="py-12">
<div class="text-center space-y-4">
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<h3 class="text-lg font-medium">No reading lists yet</h3>
<p class="text-muted-foreground">
Create your first reading list to start organizing your novels.
</p>
</div>
<Button onclick={openCreateDialog}>
<Plus class="mr-2 h-4 w-4" />
Create Your First List
</Button>
</div>
</CardContent>
</Card>
{:else}
<!-- Reading lists grid -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each readingLists as list (list.id)}
<a href={`/reading-lists/${list.id}`} class="block group">
<Card class="h-full transition-colors hover:border-primary/50">
<CardHeader class="pb-2">
<div class="flex items-start justify-between">
<CardTitle class="line-clamp-1">{list.name}</CardTitle>
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
openEditDialog(list);
}}
>
<Pencil class="h-4 w-4" />
<span class="sr-only">Edit</span>
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
onclick={(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
openDeleteDialog(list);
}}
>
<Trash2 class="h-4 w-4" />
<span class="sr-only">Delete</span>
</Button>
</div>
</div>
{#if list.description}
<CardDescription class="line-clamp-2">{list.description}</CardDescription>
{/if}
</CardHeader>
<CardFooter class="pt-2">
<span class="text-sm text-muted-foreground">
{list.itemCount} {list.itemCount === 1 ? 'novel' : 'novels'}
</span>
</CardFooter>
</Card>
</a>
{/each}
</div>
{/if}
</div>
<!-- Create/Edit Dialog -->
<DialogPrimitive.Root bind:open={dialogOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content class="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg">
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
<DialogPrimitive.Title class="text-lg font-semibold leading-none tracking-tight">
{dialogMode === 'create' ? 'Create Reading List' : 'Edit Reading List'}
</DialogPrimitive.Title>
<DialogPrimitive.Description class="text-sm text-muted-foreground">
{dialogMode === 'create' ? 'Create a new reading list to organize your novels.' : 'Update your reading list details.'}
</DialogPrimitive.Description>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<label for="list-name" class="text-sm font-medium">Name</label>
<Input
id="list-name"
type="text"
placeholder="My Reading List"
bind:value={formName}
disabled={formSubmitting}
/>
</div>
<div class="space-y-2">
<label for="list-description" class="text-sm font-medium">Description (optional)</label>
<Textarea
id="list-description"
placeholder="A collection of..."
bind:value={formDescription}
disabled={formSubmitting}
class="min-h-[80px] resize-none"
/>
</div>
{#if formError}
<p class="text-sm text-destructive">{formError}</p>
{/if}
<div class="flex justify-end gap-2">
<Button variant="outline" type="button" disabled={formSubmitting} onclick={() => dialogOpen = false}>
Cancel
</Button>
<Button type="submit" disabled={formSubmitting || !formName.trim()}>
{#if formSubmitting}
{dialogMode === 'create' ? 'Creating...' : 'Saving...'}
{:else}
{dialogMode === 'create' ? 'Create' : 'Save'}
{/if}
</Button>
</div>
</form>
<DialogPrimitive.Close class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
<!-- Delete Confirmation Dialog -->
<DialogPrimitive.Root bind:open={deleteDialogOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content class="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg">
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
<DialogPrimitive.Title class="text-lg font-semibold leading-none tracking-tight">
Delete Reading List
</DialogPrimitive.Title>
<DialogPrimitive.Description class="text-sm text-muted-foreground">
Are you sure you want to delete "{deletingList?.name}"? This action cannot be undone.
</DialogPrimitive.Description>
</div>
{#if deleteError}
<p class="text-sm text-destructive">{deleteError}</p>
{/if}
<div class="flex justify-end gap-2">
<Button variant="outline" type="button" disabled={deleteSubmitting} onclick={() => deleteDialogOpen = false}>
Cancel
</Button>
<Button variant="destructive" onclick={handleDelete} disabled={deleteSubmitting}>
{deleteSubmitting ? 'Deleting...' : 'Delete'}
</Button>
</div>
<DialogPrimitive.Close class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>

View File

@@ -0,0 +1,18 @@
import { Popover as PopoverPrimitive } from 'bits-ui';
import Content from './popover-content.svelte';
const Root = PopoverPrimitive.Root;
const Trigger = PopoverPrimitive.Trigger;
const Close = PopoverPrimitive.Close;
export {
Root,
Trigger,
Content,
Close,
//
Root as Popover,
Trigger as PopoverTrigger,
Content as PopoverContent,
Close as PopoverClose
};

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
sideOffset = 4,
align = 'center',
class: className,
...restProps
}: PopoverPrimitive.ContentProps = $props();
</script>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 overflow-hidden rounded-md border p-4 shadow-md outline-none',
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>

View File

@@ -0,0 +1,7 @@
import Root from './textarea.svelte';
export {
Root,
//
Root as Textarea
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { HTMLTextareaAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
type Props = WithElementRef<HTMLTextareaAttributes, HTMLTextAreaElement>;
let {
ref = $bindable(null),
value = $bindable(),
class: className,
'data-slot': dataSlot = 'textarea',
...restProps
}: Props = $props();
</script>
<textarea
bind:this={ref}
data-slot={dataSlot}
class={cn(
'border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex min-h-[80px] w-full min-w-0 rounded-md border px-3 py-2 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
bind:value
{...restProps}
></textarea>

View File

@@ -18,6 +18,18 @@ export type Scalars = {
UnsignedInt: { input: any; output: any; }
};
export type AddToReadingListError = InvalidOperationError;
export type AddToReadingListInput = {
novelId: Scalars['UnsignedInt']['input'];
readingListId: Scalars['Int']['input'];
};
export type AddToReadingListPayload = {
errors: Maybe<Array<AddToReadingListError>>;
readingListPayload: Maybe<ReadingListPayload>;
};
/** Defines when a policy shall be executed. */
export const ApplyPolicy = {
/** After the resolver was executed. */
@@ -29,6 +41,19 @@ export const ApplyPolicy = {
} as const;
export type ApplyPolicy = typeof ApplyPolicy[keyof typeof ApplyPolicy];
export type BookmarkDto = {
chapterId: Scalars['UnsignedInt']['output'];
createdTime: Scalars['Instant']['output'];
description: Maybe<Scalars['String']['output']>;
id: Scalars['Int']['output'];
novelId: Scalars['UnsignedInt']['output'];
};
export type BookmarkPayload = {
bookmark: Maybe<BookmarkDto>;
success: Scalars['Boolean']['output'];
};
export type ChapterDto = {
body: Scalars['String']['output'];
createdTime: Scalars['Instant']['output'];
@@ -83,6 +108,18 @@ export type ChapterReaderDto = {
volumeOrder: Scalars['Int']['output'];
};
export type CreateReadingListError = InvalidOperationError;
export type CreateReadingListInput = {
description?: InputMaybe<Scalars['String']['input']>;
name: Scalars['String']['input'];
};
export type CreateReadingListPayload = {
errors: Maybe<Array<CreateReadingListError>>;
readingListPayload: Maybe<ReadingListPayload>;
};
export type DeleteJobError = KeyNotFoundError;
export type DeleteJobInput = {
@@ -105,6 +142,17 @@ export type DeleteNovelPayload = {
errors: Maybe<Array<DeleteNovelError>>;
};
export type DeleteReadingListError = InvalidOperationError;
export type DeleteReadingListInput = {
id: Scalars['Int']['input'];
};
export type DeleteReadingListPayload = {
errors: Maybe<Array<DeleteReadingListError>>;
success: Maybe<Scalars['Boolean']['output']>;
};
export type DuplicateNameError = Error & {
message: Scalars['String']['output'];
};
@@ -261,14 +309,32 @@ export type ListFilterInputTypeOfVolumeDtoFilterInput = {
};
export type Mutation = {
addToReadingList: AddToReadingListPayload;
createReadingList: CreateReadingListPayload;
deleteJob: DeleteJobPayload;
deleteNovel: DeleteNovelPayload;
deleteReadingList: DeleteReadingListPayload;
fetchChapterContents: FetchChapterContentsPayload;
importNovel: ImportNovelPayload;
inviteUser: InviteUserPayload;
removeBookmark: RemoveBookmarkPayload;
removeFromReadingList: RemoveFromReadingListPayload;
reorderReadingListItem: ReorderReadingListItemPayload;
runJob: RunJobPayload;
scheduleEventJob: ScheduleEventJobPayload;
translateText: TranslateTextPayload;
updateReadingList: UpdateReadingListPayload;
upsertBookmark: UpsertBookmarkPayload;
};
export type MutationAddToReadingListArgs = {
input: AddToReadingListInput;
};
export type MutationCreateReadingListArgs = {
input: CreateReadingListInput;
};
@@ -282,6 +348,11 @@ export type MutationDeleteNovelArgs = {
};
export type MutationDeleteReadingListArgs = {
input: DeleteReadingListInput;
};
export type MutationFetchChapterContentsArgs = {
input: FetchChapterContentsInput;
};
@@ -297,6 +368,21 @@ export type MutationInviteUserArgs = {
};
export type MutationRemoveBookmarkArgs = {
input: RemoveBookmarkInput;
};
export type MutationRemoveFromReadingListArgs = {
input: RemoveFromReadingListInput;
};
export type MutationReorderReadingListItemArgs = {
input: ReorderReadingListItemInput;
};
export type MutationRunJobArgs = {
input: RunJobInput;
};
@@ -311,6 +397,16 @@ export type MutationTranslateTextArgs = {
input: TranslateTextInput;
};
export type MutationUpdateReadingListArgs = {
input: UpdateReadingListInput;
};
export type MutationUpsertBookmarkArgs = {
input: UpsertBookmarkInput;
};
export type NovelDto = {
author: PersonDto;
coverImage: Maybe<ImageDto>;
@@ -471,15 +567,23 @@ export type PersonDtoSortInput = {
};
export type Query = {
bookmarks: Array<BookmarkDto>;
chapter: Maybe<ChapterReaderDto>;
currentUser: Maybe<UserDto>;
jobs: Array<SchedulerJob>;
novels: Maybe<NovelsConnection>;
readingList: Maybe<ReadingListDto>;
readingLists: Array<ReadingListDto>;
translationEngines: Array<TranslationEngineDescriptor>;
translationRequests: Maybe<TranslationRequestsConnection>;
};
export type QueryBookmarksArgs = {
novelId: Scalars['UnsignedInt']['input'];
};
export type QueryChapterArgs = {
chapterOrder: Scalars['UnsignedInt']['input'];
novelId: Scalars['UnsignedInt']['input'];
@@ -499,6 +603,11 @@ export type QueryNovelsArgs = {
};
export type QueryReadingListArgs = {
id: Scalars['Int']['input'];
};
export type QueryTranslationEnginesArgs = {
order?: InputMaybe<Array<TranslationEngineDescriptorSortInput>>;
where?: InputMaybe<TranslationEngineDescriptorFilterInput>;
@@ -514,6 +623,62 @@ export type QueryTranslationRequestsArgs = {
where?: InputMaybe<TranslationRequestDtoFilterInput>;
};
export type ReadingListDto = {
createdTime: Scalars['Instant']['output'];
description: Maybe<Scalars['String']['output']>;
id: Scalars['Int']['output'];
itemCount: Scalars['Int']['output'];
items: Array<ReadingListItemDto>;
name: Scalars['String']['output'];
};
export type ReadingListItemDto = {
addedTime: Scalars['Instant']['output'];
novelId: Scalars['UnsignedInt']['output'];
order: Scalars['Int']['output'];
};
export type ReadingListPayload = {
readingList: Maybe<ReadingListDto>;
success: Scalars['Boolean']['output'];
};
export type RemoveBookmarkError = InvalidOperationError;
export type RemoveBookmarkInput = {
chapterId: Scalars['UnsignedInt']['input'];
};
export type RemoveBookmarkPayload = {
bookmarkPayload: Maybe<BookmarkPayload>;
errors: Maybe<Array<RemoveBookmarkError>>;
};
export type RemoveFromReadingListError = InvalidOperationError;
export type RemoveFromReadingListInput = {
listId: Scalars['Int']['input'];
novelId: Scalars['UnsignedInt']['input'];
};
export type RemoveFromReadingListPayload = {
errors: Maybe<Array<RemoveFromReadingListError>>;
readingListPayload: Maybe<ReadingListPayload>;
};
export type ReorderReadingListItemError = InvalidOperationError;
export type ReorderReadingListItemInput = {
newOrder: Scalars['Int']['input'];
novelId: Scalars['UnsignedInt']['input'];
readingListId: Scalars['Int']['input'];
};
export type ReorderReadingListItemPayload = {
errors: Maybe<Array<ReorderReadingListItemError>>;
readingListPayload: Maybe<ReadingListPayload>;
};
export type RunJobError = JobPersistenceError;
export type RunJobInput = {
@@ -739,6 +904,32 @@ export type UnsignedIntOperationFilterInputType = {
nlte?: InputMaybe<Scalars['UnsignedInt']['input']>;
};
export type UpdateReadingListError = InvalidOperationError;
export type UpdateReadingListInput = {
description?: InputMaybe<Scalars['String']['input']>;
id: Scalars['Int']['input'];
name: Scalars['String']['input'];
};
export type UpdateReadingListPayload = {
errors: Maybe<Array<UpdateReadingListError>>;
readingListPayload: Maybe<ReadingListPayload>;
};
export type UpsertBookmarkError = InvalidOperationError;
export type UpsertBookmarkInput = {
chapterId: Scalars['UnsignedInt']['input'];
description?: InputMaybe<Scalars['String']['input']>;
novelId: Scalars['UnsignedInt']['input'];
};
export type UpsertBookmarkPayload = {
bookmarkPayload: Maybe<BookmarkPayload>;
errors: Maybe<Array<UpsertBookmarkError>>;
};
export type UserDto = {
availableInvites: Scalars['Int']['output'];
createdTime: Scalars['Instant']['output'];
@@ -786,6 +977,20 @@ export type VolumeDtoFilterInput = {
order?: InputMaybe<IntOperationFilterInput>;
};
export type AddToReadingListMutationVariables = Exact<{
input: AddToReadingListInput;
}>;
export type AddToReadingListMutation = { addToReadingList: { readingListPayload: { success: boolean, readingList: { id: number, name: string, itemCount: number } | null } | null, errors: Array<{ message: string }> | null } };
export type CreateReadingListMutationVariables = Exact<{
input: CreateReadingListInput;
}>;
export type CreateReadingListMutation = { createReadingList: { readingListPayload: { success: boolean, readingList: { id: number, name: string, description: string | null, itemCount: number, createdTime: any } | null } | null, errors: Array<{ message: string }> | null } };
export type DeleteNovelMutationVariables = Exact<{
input: DeleteNovelInput;
}>;
@@ -793,6 +998,13 @@ export type DeleteNovelMutationVariables = Exact<{
export type DeleteNovelMutation = { deleteNovel: { boolean: boolean | null, errors: Array<{ message: string }> | null } };
export type DeleteReadingListMutationVariables = Exact<{
input: DeleteReadingListInput;
}>;
export type DeleteReadingListMutation = { deleteReadingList: { success: boolean | null, errors: Array<{ message: string }> | null } };
export type ImportNovelMutationVariables = Exact<{
input: ImportNovelInput;
}>;
@@ -807,6 +1019,48 @@ export type InviteUserMutationVariables = Exact<{
export type InviteUserMutation = { inviteUser: { userDto: { id: any, username: string, email: string } | null, errors: Array<{ message: string }> | null } };
export type RemoveBookmarkMutationVariables = Exact<{
input: RemoveBookmarkInput;
}>;
export type RemoveBookmarkMutation = { removeBookmark: { bookmarkPayload: { success: boolean } | null, errors: Array<{ message: string }> | null } };
export type RemoveFromReadingListMutationVariables = Exact<{
input: RemoveFromReadingListInput;
}>;
export type RemoveFromReadingListMutation = { removeFromReadingList: { readingListPayload: { success: boolean, readingList: { id: number, name: string, itemCount: number } | null } | null, errors: Array<{ message: string }> | null } };
export type ReorderReadingListItemMutationVariables = Exact<{
input: ReorderReadingListItemInput;
}>;
export type ReorderReadingListItemMutation = { reorderReadingListItem: { readingListPayload: { success: boolean } | null, errors: Array<{ message: string }> | null } };
export type UpdateReadingListMutationVariables = Exact<{
input: UpdateReadingListInput;
}>;
export type UpdateReadingListMutation = { updateReadingList: { readingListPayload: { success: boolean, readingList: { id: number, name: string, description: string | null, itemCount: number, createdTime: any } | null } | null, errors: Array<{ message: string }> | null } };
export type UpsertBookmarkMutationVariables = Exact<{
input: UpsertBookmarkInput;
}>;
export type UpsertBookmarkMutation = { upsertBookmark: { bookmarkPayload: { success: boolean, bookmark: { id: number, chapterId: any, novelId: any, description: string | null, createdTime: any } | null } | null, errors: Array<{ message: string }> | null } };
export type GetBookmarksQueryVariables = Exact<{
novelId: Scalars['UnsignedInt']['input'];
}>;
export type GetBookmarksQuery = { bookmarks: Array<{ id: number, chapterId: any, novelId: any, description: string | null, createdTime: any }> };
export type GetChapterQueryVariables = Exact<{
novelId: Scalars['UnsignedInt']['input'];
volumeOrder: Scalars['UnsignedInt']['input'];
@@ -833,16 +1087,45 @@ export type NovelsQueryVariables = Exact<{
export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id: any, url: string, name: string, description: string, rawStatus: NovelStatus, lastUpdatedTime: any, coverImage: { newPath: string | null } | null, volumes: Array<{ id: any, order: number, name: string, chapters: Array<{ order: any, name: string }> }>, tags: Array<{ key: string, displayName: string, tagType: TagType }> } }> | null, pageInfo: { hasNextPage: boolean, endCursor: string | null } } | null };
export type GetReadingListQueryVariables = Exact<{
id: Scalars['Int']['input'];
}>;
export type GetReadingListQuery = { readingList: { id: number, name: string, description: string | null, itemCount: number, createdTime: any, items: Array<{ novelId: any, order: number, addedTime: any }> } | null };
export type GetReadingListsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetReadingListsQuery = { readingLists: Array<{ id: number, name: string, description: string | null, itemCount: number, createdTime: any }> };
export type GetReadingListsWithItemsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetReadingListsWithItemsQuery = { readingLists: Array<{ id: number, name: string, description: string | null, itemCount: number, createdTime: any, items: Array<{ novelId: any, order: number, addedTime: any }> }> };
export type GetSettingsPageDataQueryVariables = Exact<{ [key: string]: never; }>;
export type GetSettingsPageDataQuery = { currentUser: { id: any, username: string, availableInvites: number, invitedUsers: Array<{ username: string, email: string }> | null } | null };
export const AddToReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddToReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddToReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addToReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<AddToReadingListMutation, AddToReadingListMutationVariables>;
export const CreateReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<CreateReadingListMutation, CreateReadingListMutationVariables>;
export const DeleteNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boolean"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<DeleteNovelMutation, DeleteNovelMutationVariables>;
export const DeleteReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<DeleteReadingListMutation, DeleteReadingListMutationVariables>;
export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUpdateRequestedEvent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}}]}}]}}]} as unknown as DocumentNode<ImportNovelMutation, ImportNovelMutationVariables>;
export const InviteUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InviteUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InviteUserInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userDto"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InvalidOperationError"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<InviteUserMutation, InviteUserMutationVariables>;
export const RemoveBookmarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveBookmark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveBookmarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeBookmark"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarkPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<RemoveBookmarkMutation, RemoveBookmarkMutationVariables>;
export const RemoveFromReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveFromReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveFromReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeFromReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<RemoveFromReadingListMutation, RemoveFromReadingListMutationVariables>;
export const ReorderReadingListItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReorderReadingListItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ReorderReadingListItemInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"reorderReadingListItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<ReorderReadingListItemMutation, ReorderReadingListItemMutationVariables>;
export const UpdateReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<UpdateReadingListMutation, UpdateReadingListMutationVariables>;
export const UpsertBookmarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpsertBookmark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpsertBookmarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"upsertBookmark"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarkPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"bookmark"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"chapterId"}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<UpsertBookmarkMutation, UpsertBookmarkMutationVariables>;
export const GetBookmarksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBookmarks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"chapterId"}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}}]} as unknown as DocumentNode<GetBookmarksQuery, GetBookmarksQueryVariables>;
export const GetChapterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapter"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"volumeOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"volumeOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"volumeOrder"}}},{"kind":"Argument","name":{"kind":"Name","value":"chapterOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"revision"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"novelName"}},{"kind":"Field","name":{"kind":"Name","value":"volumeId"}},{"kind":"Field","name":{"kind":"Name","value":"volumeName"}},{"kind":"Field","name":{"kind":"Name","value":"volumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"totalChaptersInVolume"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterVolumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterVolumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterOrder"}}]}}]}}]} as unknown as DocumentNode<GetChapterQuery, GetChapterQueryVariables>;
export const NovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rawLanguage"}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"statusOverride"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"externalUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"source"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"volumes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode<NovelQuery, NovelQueryVariables>;
export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"order"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoSortInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"Variable","name":{"kind":"Name","value":"order"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"volumes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode<NovelsQuery, NovelsQueryVariables>;
export const GetReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"addedTime"}}]}}]}}]}}]} as unknown as DocumentNode<GetReadingListQuery, GetReadingListQueryVariables>;
export const GetReadingListsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReadingLists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingLists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}}]} as unknown as DocumentNode<GetReadingListsQuery, GetReadingListsQueryVariables>;
export const GetReadingListsWithItemsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReadingListsWithItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingLists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"addedTime"}}]}}]}}]}}]} as unknown as DocumentNode<GetReadingListsWithItemsQuery, GetReadingListsWithItemsQueryVariables>;
export const GetSettingsPageDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSettingsPageData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"availableInvites"}},{"kind":"Field","name":{"kind":"Name","value":"invitedUsers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]}}]} as unknown as DocumentNode<GetSettingsPageDataQuery, GetSettingsPageDataQueryVariables>;

View File

@@ -0,0 +1,17 @@
mutation AddToReadingList($input: AddToReadingListInput!) {
addToReadingList(input: $input) {
readingListPayload {
success
readingList {
id
name
itemCount
}
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,19 @@
mutation CreateReadingList($input: CreateReadingListInput!) {
createReadingList(input: $input) {
readingListPayload {
success
readingList {
id
name
description
itemCount
createdTime
}
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,10 @@
mutation DeleteReadingList($input: DeleteReadingListInput!) {
deleteReadingList(input: $input) {
success
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,12 @@
mutation RemoveBookmark($input: RemoveBookmarkInput!) {
removeBookmark(input: $input) {
bookmarkPayload {
success
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,17 @@
mutation RemoveFromReadingList($input: RemoveFromReadingListInput!) {
removeFromReadingList(input: $input) {
readingListPayload {
success
readingList {
id
name
itemCount
}
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,12 @@
mutation ReorderReadingListItem($input: ReorderReadingListItemInput!) {
reorderReadingListItem(input: $input) {
readingListPayload {
success
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,19 @@
mutation UpdateReadingList($input: UpdateReadingListInput!) {
updateReadingList(input: $input) {
readingListPayload {
success
readingList {
id
name
description
itemCount
createdTime
}
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,19 @@
mutation UpsertBookmark($input: UpsertBookmarkInput!) {
upsertBookmark(input: $input) {
bookmarkPayload {
success
bookmark {
id
chapterId
novelId
description
createdTime
}
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,9 @@
query GetBookmarks($novelId: UnsignedInt!) {
bookmarks(novelId: $novelId) {
id
chapterId
novelId
description
createdTime
}
}

View File

@@ -0,0 +1,14 @@
query GetReadingList($id: Int!) {
readingList(id: $id) {
id
name
description
itemCount
createdTime
items {
novelId
order
addedTime
}
}
}

View File

@@ -0,0 +1,9 @@
query GetReadingLists {
readingLists {
id
name
description
itemCount
createdTime
}
}

View File

@@ -0,0 +1,14 @@
query GetReadingListsWithItems {
readingLists {
id
name
description
itemCount
createdTime
items {
novelId
order
addedTime
}
}
}

View File

@@ -1,10 +1,64 @@
---
import { print } from 'graphql';
import AppLayout from '../../../../../../layouts/AppLayout.astro';
import ChapterReaderPage from '../../../../../../lib/components/ChapterReaderPage.svelte';
import { GetChapterDocument } from '../../../../../../lib/graphql/__generated__/graphql';
const { id, volumeOrder, chapterNumber } = Astro.params;
const token = Astro.cookies.get('fa_session')?.value;
let chapter = null;
let authFailed = false;
let fetchError = null;
if (token) {
try {
const response = await fetch(import.meta.env.PUBLIC_GRAPHQL_URI, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
query: print(GetChapterDocument),
variables: {
novelId: parseInt(id!, 10),
volumeOrder: parseInt(volumeOrder!, 10),
chapterOrder: parseInt(chapterNumber!, 10)
}
})
});
if (!response.ok) {
fetchError = `Server error: ${response.status}`;
} else {
const result = await response.json();
if (result.errors?.some((e: { extensions?: { code?: string } }) => e.extensions?.code === 'AUTH_NOT_AUTHENTICATED')) {
authFailed = true;
} else if (result.data?.chapter) {
chapter = result.data.chapter;
} else {
fetchError = result.errors?.[0]?.message ?? 'Chapter not found';
}
}
} catch (e) {
fetchError = e instanceof Error ? e.message : 'Failed to fetch chapter';
}
} else {
authFailed = true;
}
---
<AppLayout title="Reading - FictionArchive">
<ChapterReaderPage novelId={id} volumeOrder={volumeOrder} chapterNumber={chapterNumber} client:load />
<AppLayout title={chapter ? `${chapter.novelName} - Chapter ${chapter.order}` : 'Reading - FictionArchive'}>
<ChapterReaderPage
novelId={id}
volumeOrder={volumeOrder}
chapterNumber={chapterNumber}
initialChapter={chapter}
initialAuthFailed={authFailed}
initialError={fetchError}
client:load
/>
</AppLayout>

View File

@@ -0,0 +1,10 @@
---
import AppLayout from '../../layouts/AppLayout.astro';
import ReadingListDetailPage from '../../lib/components/ReadingListDetailPage.svelte';
const { id } = Astro.params;
---
<AppLayout title="Reading List - FictionArchive">
<ReadingListDetailPage listId={id} client:load />
</AppLayout>

View File

@@ -0,0 +1,8 @@
---
import AppLayout from '../../layouts/AppLayout.astro';
import ReadingListsPage from '../../lib/components/ReadingListsPage.svelte';
---
<AppLayout title="Reading Lists - FictionArchive">
<ReadingListsPage client:load />
</AppLayout>