Compare commits

...

3 Commits

Author SHA1 Message Date
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
gamer147
5013da69c2 [FA-27] UserNovelDataService bootstrapped, going to do author's posts first i think 2025-12-29 14:54:01 -05:00
33 changed files with 1241 additions and 16 deletions

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,109 @@
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 };
}
}

View File

@@ -0,0 +1,45 @@
using System.Security.Claims;
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
});
}
}

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,96 @@
// <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.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,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 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,8 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class Chapter : BaseEntity<uint>
{
}

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; }
}

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,8 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class Volume : BaseEntity<uint>
{
public virtual ICollection<Chapter> Chapters { get; set; }
}

View File

@@ -0,0 +1,75 @@
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.Services;
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);
});
}
#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,35 @@
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 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);
});
}
}

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

@@ -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

@@ -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';
@@ -28,6 +29,10 @@
let error: string | null = $state(null);
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 +83,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,6 +95,34 @@
}
}
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(() => {
fetchChapter();
window.addEventListener('scroll', handleScroll, { passive: true });
@@ -139,10 +174,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 +212,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

@@ -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';
@@ -98,6 +100,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 +135,15 @@
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 }])
)
)
);
const chapterCount = $derived(
sortedVolumes.reduce((sum, v) => sum + v.chapters.length, 0)
);
@@ -184,6 +200,34 @@
}
});
// Load bookmarks when tab is first activated
$effect(() => {
if (activeTab === 'bookmarks' && !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;
@@ -528,10 +572,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>
@@ -646,9 +689,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,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

@@ -29,6 +29,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'];
@@ -266,9 +279,11 @@ export type Mutation = {
fetchChapterContents: FetchChapterContentsPayload;
importNovel: ImportNovelPayload;
inviteUser: InviteUserPayload;
removeBookmark: RemoveBookmarkPayload;
runJob: RunJobPayload;
scheduleEventJob: ScheduleEventJobPayload;
translateText: TranslateTextPayload;
upsertBookmark: UpsertBookmarkPayload;
};
@@ -297,6 +312,11 @@ export type MutationInviteUserArgs = {
};
export type MutationRemoveBookmarkArgs = {
input: RemoveBookmarkInput;
};
export type MutationRunJobArgs = {
input: RunJobInput;
};
@@ -311,6 +331,11 @@ export type MutationTranslateTextArgs = {
input: TranslateTextInput;
};
export type MutationUpsertBookmarkArgs = {
input: UpsertBookmarkInput;
};
export type NovelDto = {
author: PersonDto;
coverImage: Maybe<ImageDto>;
@@ -471,6 +496,7 @@ export type PersonDtoSortInput = {
};
export type Query = {
bookmarks: Array<BookmarkDto>;
chapter: Maybe<ChapterReaderDto>;
currentUser: Maybe<UserDto>;
jobs: Array<SchedulerJob>;
@@ -480,6 +506,11 @@ export type Query = {
};
export type QueryBookmarksArgs = {
novelId: Scalars['UnsignedInt']['input'];
};
export type QueryChapterArgs = {
chapterOrder: Scalars['UnsignedInt']['input'];
novelId: Scalars['UnsignedInt']['input'];
@@ -514,6 +545,17 @@ export type QueryTranslationRequestsArgs = {
where?: InputMaybe<TranslationRequestDtoFilterInput>;
};
export type RemoveBookmarkError = InvalidOperationError;
export type RemoveBookmarkInput = {
chapterId: Scalars['UnsignedInt']['input'];
};
export type RemoveBookmarkPayload = {
bookmarkPayload: Maybe<BookmarkPayload>;
errors: Maybe<Array<RemoveBookmarkError>>;
};
export type RunJobError = JobPersistenceError;
export type RunJobInput = {
@@ -739,6 +781,19 @@ export type UnsignedIntOperationFilterInputType = {
nlte?: InputMaybe<Scalars['UnsignedInt']['input']>;
};
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'];
@@ -807,6 +862,27 @@ 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 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'];
@@ -842,6 +918,9 @@ export type GetSettingsPageDataQuery = { currentUser: { id: any, username: strin
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 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 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>;

View File

@@ -0,0 +1,12 @@
mutation RemoveBookmark($input: RemoveBookmarkInput!) {
removeBookmark(input: $input) {
bookmarkPayload {
success
}
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
}
}