10 Commits

Author SHA1 Message Date
gamer147
e70c39ea75 [FA-misc] Refresh button, UI mostly gold
All checks were successful
CI / build-backend (pull_request) Successful in 1m45s
CI / build-frontend (pull_request) Successful in 39s
2025-12-09 09:11:39 -05:00
gamer147
81e4e88ad4 [FA-misc] Switches to using DTOs, updates frontend with details and reader page, updates novel import to be an upsert 2025-12-08 18:30:00 -05:00
c9d93a4e55 Delete .gitea/workflows/claude_assistant.yml
All checks were successful
CI / build-backend (push) Successful in 1m7s
CI / build-frontend (push) Successful in 36s
2025-12-07 20:13:42 +00:00
9527d94928 Merge pull request '[FA-misc] Fix build pipelines' (#40) from feature/FA-misc_AstroMigration into master
Some checks failed
CI / build-backend (push) Has been cancelled
CI / build-frontend (push) Has been cancelled
Reviewed-on: #40
2025-12-07 20:13:20 +00:00
gamer147
c2fdeca6c4 [FA-misc] Fix build pipelines
All checks were successful
CI / build-backend (pull_request) Successful in 1m2s
CI / build-frontend (pull_request) Successful in 39s
2025-12-07 15:11:56 -05:00
aae17021af Merge pull request 'feature/FA-misc_AstroMigration' (#36) from feature/FA-misc_AstroMigration into master
Some checks failed
CI / build-backend (push) Successful in 53s
CI / build-frontend (push) Failing after 14s
Reviewed-on: #36
2025-12-01 12:27:36 +00:00
gamer147
c60aaf2bdb [FA-misc] Should be good
Some checks failed
CI / build-backend (pull_request) Successful in 1m49s
CI / build-frontend (pull_request) Failing after 16s
2025-12-01 07:26:38 -05:00
gamer147
b2f4548807 Should be mostly working, doing some additional QOL 2025-11-30 23:00:40 -05:00
gamer147
8d6f0d6cfd [FA-misc] Astro migration works, probably want to touchup the frontend but that can be in Phase 4 2025-11-28 10:43:51 -05:00
bc83bffb4b Merge pull request 'feat: implement authentication system for API Gateway and FileService' (#34) from claude/issue-17-add-authentication into master
All checks were successful
CI / build-backend (push) Successful in 1m5s
CI / build-frontend (push) Successful in 27s
Reviewed-on: #34
2025-11-28 04:26:23 +00:00
156 changed files with 18022 additions and 9549 deletions

View File

@@ -53,7 +53,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
working-directory: fictionarchive-web working-directory: fictionarchive-web-astro
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -1,49 +0,0 @@
name: Claude PR Assistant
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude-code-action:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude PR Action
uses: markwylde/claude-code-gitea-action@v1.0.20
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
gitea_token: ${{ secrets.CLAUDE_GITEA_TOKEN }}
# Or use OAuth token instead:
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
timeout_minutes: "60"
# mode: tag # Default: responds to @claude mentions
# Optional: Restrict network access to specific domains only
# experimental_allowed_domains: |
# .anthropic.com
# .github.com
# api.github.com
# .githubusercontent.com
# bun.sh
# registry.npmjs.org
# .blob.core.windows.net

View File

@@ -88,15 +88,16 @@ jobs:
- name: Build and push frontend Docker image - name: Build and push frontend Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ./fictionarchive-web context: ./fictionarchive-web-astro
file: fictionarchive-web/Dockerfile file: fictionarchive-web-astro/Dockerfile
push: true push: true
build-args: | build-args: |
VITE_GRAPHQL_URI=${{ vars.VITE_GRAPHQL_URI }} PUBLIC_GRAPHQL_URI=${{ vars.PUBLIC_GRAPHQL_URI }}
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }} PUBLIC_OIDC_AUTHORITY=${{ vars.PUBLIC_OIDC_AUTHORITY }}
VITE_OIDC_CLIENT_ID=${{ vars.VITE_OIDC_CLIENT_ID }} PUBLIC_OIDC_CLIENT_ID=${{ vars.PUBLIC_OIDC_CLIENT_ID }}
VITE_OIDC_REDIRECT_URI=${{ vars.VITE_OIDC_REDIRECT_URI }} PUBLIC_OIDC_REDIRECT_URI=${{ vars.PUBLIC_OIDC_REDIRECT_URI }}
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${{ vars.VITE_OIDC_POST_LOGOUT_REDIRECT_URI }} PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI=${{ vars.PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI }}
PUBLIC_OIDC_SCOPE=${{ vars.PUBLIC_OIDC_SCOPE }}
tags: | tags: |
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-frontend:${{ steps.version.outputs.VERSION }} ${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-frontend:${{ steps.version.outputs.VERSION }}
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-frontend:latest ${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-frontend:latest

View File

@@ -12,8 +12,10 @@ public class Program
#region Fusion Gateway #region Fusion Gateway
// Register header propagation service to forward Authorization header to subgraphs
builder.Services.AddHttpClient("Fusion") builder.Services.AddHttpClient("Fusion")
.AddHeaderPropagation(opt => .AddHeaderPropagation();
builder.Services.AddHeaderPropagation(opt =>
{ {
opt.Headers.Add("Authorization"); opt.Headers.Add("Authorization");
}); });
@@ -23,17 +25,17 @@ public class Program
.ConfigureFromFile("gateway.fgp") .ConfigureFromFile("gateway.fgp")
.CoreBuilder.ApplySaneDefaults(); .CoreBuilder.ApplySaneDefaults();
#endregion
// Add authentication
builder.Services.AddOidcAuthentication(builder.Configuration); builder.Services.AddOidcAuthentication(builder.Configuration);
#endregion
var allowedOrigin = builder.Configuration["Cors:AllowedOrigin"] ?? "http://localhost:4321";
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy("AllowFictionArchiveOrigins", options.AddPolicy("AllowFictionArchiveOrigins",
policyBuilder => policyBuilder =>
{ {
policyBuilder.WithOrigins("https://fictionarchive.orfl.xyz", "http://localhost:5173") policyBuilder.WithOrigins(allowedOrigin)
.AllowAnyMethod() .AllowAnyMethod()
.AllowAnyHeader() .AllowAnyHeader()
.AllowCredentials(); .AllowCredentials();

View File

@@ -6,10 +6,13 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Cors": {
"AllowedOrigin": "http://localhost:4321"
},
"OIDC": { "OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "fictionarchive-api", "ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "fictionarchive-api", "Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true, "ValidateIssuer": true,
"ValidateAudience": true, "ValidateAudience": true,

View File

@@ -20,8 +20,8 @@
}, },
"OIDC": { "OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "fictionarchive-files", "ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "fictionarchive-api", "Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true, "ValidateIssuer": true,
"ValidateAudience": true, "ValidateAudience": true,

View File

@@ -162,4 +162,138 @@ public class NovelUpdateServiceTests
} }
private record NovelCreateResult(Novel Novel, Chapter Chapter); private record NovelCreateResult(Novel Novel, Chapter Chapter);
#region UpdateImage Tests
[Fact]
public async Task UpdateImage_sets_NewPath_on_image_without_chapter()
{
// Arrange
using var dbContext = CreateDbContext();
var image = new Image
{
OriginalPath = "http://original/cover.jpg",
NewPath = null
};
dbContext.Images.Add(image);
await dbContext.SaveChangesAsync();
var adapter = Substitute.For<ISourceAdapter>();
var eventBus = Substitute.For<IEventBus>();
var service = CreateService(dbContext, adapter, eventBus);
var newUrl = "https://cdn.example.com/uploaded/cover.jpg";
// Act
await service.UpdateImage(image.Id, newUrl);
// Assert
var updatedImage = await dbContext.Images.FindAsync(image.Id);
updatedImage!.NewPath.Should().Be(newUrl);
updatedImage.OriginalPath.Should().Be("http://original/cover.jpg");
}
[Fact]
public async Task UpdateImage_updates_chapter_body_html_with_new_url()
{
// Arrange
using var dbContext = CreateDbContext();
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" };
var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source);
var image = new Image
{
OriginalPath = "http://original/image.jpg",
NewPath = null,
Chapter = chapter
};
chapter.Images.Add(image);
await dbContext.SaveChangesAsync();
// Set up the chapter body with an img tag referencing the image by ID (as PullChapterContents does)
var pendingUrl = "https://pending/placeholder.jpg";
var bodyHtml = $"<p>Content</p><img src=\"{pendingUrl}\" alt=\"{image.Id}\" />";
chapter.Body.Texts.Add(new LocalizationText
{
Language = Language.En,
Text = bodyHtml
});
await dbContext.SaveChangesAsync();
var adapter = Substitute.For<ISourceAdapter>();
var eventBus = Substitute.For<IEventBus>();
var service = CreateService(dbContext, adapter, eventBus, pendingUrl);
var newUrl = "https://cdn.example.com/uploaded/image.jpg";
// Act
await service.UpdateImage(image.Id, newUrl);
// Assert
var updatedImage = await dbContext.Images
.Include(i => i.Chapter)
.ThenInclude(c => c.Body)
.ThenInclude(b => b.Texts)
.FirstAsync(i => i.Id == image.Id);
updatedImage.NewPath.Should().Be(newUrl);
var updatedBodyText = updatedImage.Chapter!.Body.Texts.Single().Text;
var doc = new HtmlDocument();
doc.LoadHtml(updatedBodyText);
var imgNode = doc.DocumentNode.SelectSingleNode("//img");
imgNode.Should().NotBeNull();
imgNode!.GetAttributeValue("src", string.Empty).Should().Be(newUrl);
}
[Fact]
public async Task UpdateImage_does_not_modify_other_images_in_chapter_body()
{
// Arrange
using var dbContext = CreateDbContext();
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" };
var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source);
var image1 = new Image { OriginalPath = "http://original/img1.jpg", Chapter = chapter };
var image2 = new Image { OriginalPath = "http://original/img2.jpg", Chapter = chapter };
chapter.Images.Add(image1);
chapter.Images.Add(image2);
await dbContext.SaveChangesAsync();
var pendingUrl = "https://pending/placeholder.jpg";
var bodyHtml = $"<p>Content</p><img src=\"{pendingUrl}\" alt=\"{image1.Id}\" /><img src=\"{pendingUrl}\" alt=\"{image2.Id}\" />";
chapter.Body.Texts.Add(new LocalizationText
{
Language = Language.En,
Text = bodyHtml
});
await dbContext.SaveChangesAsync();
var adapter = Substitute.For<ISourceAdapter>();
var eventBus = Substitute.For<IEventBus>();
var service = CreateService(dbContext, adapter, eventBus, pendingUrl);
var newUrl = "https://cdn.example.com/uploaded/img1.jpg";
// Act - only update image1
await service.UpdateImage(image1.Id, newUrl);
// Assert
var updatedChapter = await dbContext.Chapters
.Include(c => c.Body)
.ThenInclude(b => b.Texts)
.FirstAsync(c => c.Id == chapter.Id);
var updatedBodyText = updatedChapter.Body.Texts.Single().Text;
var doc = new HtmlDocument();
doc.LoadHtml(updatedBodyText);
var img1Node = doc.DocumentNode.SelectSingleNode($"//img[@alt='{image1.Id}']");
var img2Node = doc.DocumentNode.SelectSingleNode($"//img[@alt='{image2.Id}']");
img1Node!.GetAttributeValue("src", string.Empty).Should().Be(newUrl);
img2Node!.GetAttributeValue("src", string.Empty).Should().Be(pendingUrl);
}
#endregion
} }

View File

@@ -1,4 +1,5 @@
using FictionArchive.Service.NovelService.Models.Novels; using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.DTOs;
using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services;
using HotChocolate.Authorization; using HotChocolate.Authorization;
using HotChocolate.Data; using HotChocolate.Data;
@@ -13,8 +14,186 @@ public class Query
[UseProjection] [UseProjection]
[UseFiltering] [UseFiltering]
[UseSorting] [UseSorting]
public IQueryable<Novel> GetNovels(NovelServiceDbContext dbContext) public IQueryable<NovelDto> GetNovels(
NovelServiceDbContext dbContext,
Language preferredLanguage = Language.En)
{ {
return dbContext.Novels.AsQueryable(); return dbContext.Novels.Select(novel => new NovelDto
{
Id = novel.Id,
CreatedTime = novel.CreatedTime,
LastUpdatedTime = novel.LastUpdatedTime,
Url = novel.Url,
RawLanguage = novel.RawLanguage,
RawStatus = novel.RawStatus,
StatusOverride = novel.StatusOverride,
ExternalId = novel.ExternalId,
Name = novel.Name.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? novel.Name.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Description = novel.Description.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? novel.Description.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Author = new PersonDto
{
Id = novel.Author.Id,
CreatedTime = novel.Author.CreatedTime,
LastUpdatedTime = novel.Author.LastUpdatedTime,
Name = novel.Author.Name.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? novel.Author.Name.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
ExternalUrl = novel.Author.ExternalUrl
},
Source = new SourceDto
{
Id = novel.Source.Id,
CreatedTime = novel.Source.CreatedTime,
LastUpdatedTime = novel.Source.LastUpdatedTime,
Name = novel.Source.Name,
Key = novel.Source.Key,
Url = novel.Source.Url
},
CoverImage = novel.CoverImage != null
? new ImageDto
{
Id = novel.CoverImage.Id,
CreatedTime = novel.CoverImage.CreatedTime,
LastUpdatedTime = novel.CoverImage.LastUpdatedTime,
NewPath = novel.CoverImage.NewPath
}
: null,
Chapters = novel.Chapters.Select(chapter => new ChapterDto
{
Id = chapter.Id,
CreatedTime = chapter.CreatedTime,
LastUpdatedTime = chapter.LastUpdatedTime,
Revision = chapter.Revision,
Order = chapter.Order,
Url = chapter.Url,
Name = chapter.Name.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? chapter.Name.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Body = chapter.Body.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? chapter.Body.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Images = chapter.Images.Select(image => new ImageDto
{
Id = image.Id,
CreatedTime = image.CreatedTime,
LastUpdatedTime = image.LastUpdatedTime,
NewPath = image.NewPath
}).ToList()
}).ToList(),
Tags = novel.Tags.Select(tag => new NovelTagDto
{
Id = tag.Id,
CreatedTime = tag.CreatedTime,
LastUpdatedTime = tag.LastUpdatedTime,
Key = tag.Key,
TagType = tag.TagType,
DisplayName = tag.DisplayName.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? tag.DisplayName.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Source = tag.Source != null
? new SourceDto
{
Id = tag.Source.Id,
CreatedTime = tag.Source.CreatedTime,
LastUpdatedTime = tag.Source.LastUpdatedTime,
Name = tag.Source.Name,
Key = tag.Source.Key,
Url = tag.Source.Url
}
: null
}).ToList()
});
}
[Authorize]
[UseFirstOrDefault]
[UseProjection]
public IQueryable<ChapterReaderDto> GetChapter(
NovelServiceDbContext dbContext,
uint novelId,
uint chapterOrder,
Language preferredLanguage = Language.En)
{
return dbContext.Chapters
.Where(c => c.Novel.Id == novelId && c.Order == chapterOrder)
.Select(chapter => new ChapterReaderDto
{
Id = chapter.Id,
CreatedTime = chapter.CreatedTime,
LastUpdatedTime = chapter.LastUpdatedTime,
Revision = chapter.Revision,
Order = chapter.Order,
Url = chapter.Url,
Name = chapter.Name.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? chapter.Name.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Body = chapter.Body.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? chapter.Body.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Images = chapter.Images.Select(image => new ImageDto
{
Id = image.Id,
CreatedTime = image.CreatedTime,
LastUpdatedTime = image.LastUpdatedTime,
NewPath = image.NewPath
}).ToList(),
NovelId = chapter.Novel.Id,
NovelName = chapter.Novel.Name.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? chapter.Novel.Name.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
TotalChapters = chapter.Novel.Chapters.Count,
PrevChapterOrder = chapter.Novel.Chapters
.Where(c => c.Order < chapterOrder)
.OrderByDescending(c => c.Order)
.Select(c => (uint?)c.Order)
.FirstOrDefault(),
NextChapterOrder = chapter.Novel.Chapters
.Where(c => c.Order > chapterOrder)
.OrderBy(c => c.Order)
.Select(c => (uint?)c.Order)
.FirstOrDefault()
});
} }
} }

View File

@@ -0,0 +1,547 @@
// <auto-generated />
using System;
using FictionArchive.Service.NovelService.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.NovelService.Migrations
{
[DbContext(typeof(NovelServiceDbContext))]
[Migration("20251208230154_FA-misc_NovelConstraint")]
partial class FAmisc_NovelConstraint
{
/// <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.NovelService.Models.Images.Image", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<long?>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("NewPath")
.HasColumnType("text");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("Images");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", 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.HasKey("Id");
b.ToTable("LocalizationKeys");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("EngineId")
.HasColumnType("bigint");
b.Property<Guid>("KeyRequestedForTranslationId")
.HasColumnType("uuid");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("TranslateTo")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("EngineId");
b.HasIndex("KeyRequestedForTranslationId");
b.ToTable("LocalizationRequests");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Language")
.HasColumnType("integer");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("LocalizationKeyId")
.HasColumnType("uuid");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TranslationEngineId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("LocalizationKeyId");
b.HasIndex("TranslationEngineId");
b.ToTable("LocalizationText");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Guid>("BodyId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order")
.HasColumnType("bigint");
b.Property<long>("Revision")
.HasColumnType("bigint");
b.Property<string>("Url")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("BodyId");
b.HasIndex("NameId");
b.HasIndex("NovelId");
b.ToTable("Chapter");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("AuthorId")
.HasColumnType("bigint");
b.Property<Guid?>("CoverImageId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("DescriptionId")
.HasColumnType("uuid");
b.Property<string>("ExternalId")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<int>("RawLanguage")
.HasColumnType("integer");
b.Property<int>("RawStatus")
.HasColumnType("integer");
b.Property<long>("SourceId")
.HasColumnType("bigint");
b.Property<int?>("StatusOverride")
.HasColumnType("integer");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("CoverImageId");
b.HasIndex("DescriptionId");
b.HasIndex("NameId");
b.HasIndex("SourceId");
b.HasIndex("ExternalId", "SourceId")
.IsUnique();
b.ToTable("Novels");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", 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<Guid>("DisplayNameId")
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("SourceId")
.HasColumnType("bigint");
b.Property<int>("TagType")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DisplayNameId");
b.HasIndex("SourceId");
b.ToTable("Tags");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", 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<string>("ExternalUrl")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("NameId");
b.ToTable("Person");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Source", 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<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Sources");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", 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<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("TranslationEngines");
});
modelBuilder.Entity("NovelNovelTag", b =>
{
b.Property<long>("NovelsId")
.HasColumnType("bigint");
b.Property<long>("TagsId")
.HasColumnType("bigint");
b.HasKey("NovelsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("NovelNovelTag");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Chapter", "Chapter")
.WithMany("Images")
.HasForeignKey("ChapterId");
b.Navigation("Chapter");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine")
.WithMany()
.HasForeignKey("EngineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "KeyRequestedForTranslation")
.WithMany()
.HasForeignKey("KeyRequestedForTranslationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Engine");
b.Navigation("KeyRequestedForTranslation");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", null)
.WithMany("Texts")
.HasForeignKey("LocalizationKeyId");
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "TranslationEngine")
.WithMany()
.HasForeignKey("TranslationEngineId");
b.Navigation("TranslationEngine");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Body")
.WithMany()
.HasForeignKey("BodyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
.WithMany()
.HasForeignKey("NameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel")
.WithMany("Chapters")
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Body");
b.Navigation("Name");
b.Navigation("Novel");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Person", "Author")
.WithMany()
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Images.Image", "CoverImage")
.WithMany()
.HasForeignKey("CoverImageId");
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Description")
.WithMany()
.HasForeignKey("DescriptionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
.WithMany()
.HasForeignKey("NameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source")
.WithMany()
.HasForeignKey("SourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
b.Navigation("CoverImage");
b.Navigation("Description");
b.Navigation("Name");
b.Navigation("Source");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "DisplayName")
.WithMany()
.HasForeignKey("DisplayNameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source")
.WithMany()
.HasForeignKey("SourceId");
b.Navigation("DisplayName");
b.Navigation("Source");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
.WithMany()
.HasForeignKey("NameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Name");
});
modelBuilder.Entity("NovelNovelTag", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)
.WithMany()
.HasForeignKey("NovelsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.NovelTag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Navigation("Texts");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b =>
{
b.Navigation("Images");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
/// <inheritdoc />
public partial class FAmisc_NovelConstraint : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapter_Novels_NovelId",
table: "Chapter");
migrationBuilder.AlterColumn<long>(
name: "NovelId",
table: "Chapter",
type: "bigint",
nullable: false,
defaultValue: 0L,
oldClrType: typeof(long),
oldType: "bigint",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_Novels_ExternalId_SourceId",
table: "Novels",
columns: new[] { "ExternalId", "SourceId" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Chapter_Novels_NovelId",
table: "Chapter",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapter_Novels_NovelId",
table: "Chapter");
migrationBuilder.DropIndex(
name: "IX_Novels_ExternalId_SourceId",
table: "Novels");
migrationBuilder.AlterColumn<long>(
name: "NovelId",
table: "Chapter",
type: "bigint",
nullable: true,
oldClrType: typeof(long),
oldType: "bigint");
migrationBuilder.AddForeignKey(
name: "FK_Chapter_Novels_NovelId",
table: "Chapter",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id");
}
}
}

View File

@@ -153,7 +153,7 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Guid>("NameId") b.Property<Guid>("NameId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<long?>("NovelId") b.Property<long>("NovelId")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<long>("Order") b.Property<long>("Order")
@@ -234,6 +234,9 @@ namespace FictionArchive.Service.NovelService.Migrations
b.HasIndex("SourceId"); b.HasIndex("SourceId");
b.HasIndex("ExternalId", "SourceId")
.IsUnique();
b.ToTable("Novels"); b.ToTable("Novels");
}); });
@@ -424,13 +427,17 @@ namespace FictionArchive.Service.NovelService.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null) b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel")
.WithMany("Chapters") .WithMany("Chapters")
.HasForeignKey("NovelId"); .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Body"); b.Navigation("Body");
b.Navigation("Name"); b.Navigation("Name");
b.Navigation("Novel");
}); });
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>

View File

@@ -0,0 +1,10 @@
using NodaTime;
namespace FictionArchive.Service.NovelService.Models.DTOs;
public abstract class BaseDto<TKey>
{
public TKey Id { get; init; }
public Instant CreatedTime { get; init; }
public Instant LastUpdatedTime { get; init; }
}

View File

@@ -0,0 +1,11 @@
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class ChapterDto : BaseDto<uint>
{
public uint Revision { get; init; }
public uint Order { get; init; }
public string? Url { get; init; }
public required string Name { get; init; }
public required string Body { get; init; }
public required List<ImageDto> Images { get; init; }
}

View File

@@ -0,0 +1,18 @@
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class ChapterReaderDto : BaseDto<uint>
{
public uint Revision { get; init; }
public uint Order { get; init; }
public string? Url { get; init; }
public required string Name { get; init; }
public required string Body { get; init; }
public required List<ImageDto> Images { get; init; }
// Navigation context
public uint NovelId { get; init; }
public required string NovelName { get; init; }
public int TotalChapters { get; init; }
public uint? PrevChapterOrder { get; init; }
public uint? NextChapterOrder { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class ImageDto : BaseDto<Guid>
{
public string? NewPath { get; init; }
}

View File

@@ -0,0 +1,20 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.Enums;
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class NovelDto : BaseDto<uint>
{
public required PersonDto Author { get; init; }
public required string Url { get; init; }
public Language RawLanguage { get; init; }
public NovelStatus RawStatus { get; init; }
public NovelStatus? StatusOverride { get; init; }
public required SourceDto Source { get; init; }
public required string ExternalId { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
public required List<ChapterDto> Chapters { get; init; }
public required List<NovelTagDto> Tags { get; init; }
public ImageDto? CoverImage { get; init; }
}

View File

@@ -0,0 +1,11 @@
using FictionArchive.Service.NovelService.Models.Enums;
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class NovelTagDto : BaseDto<uint>
{
public required string Key { get; init; }
public required string DisplayName { get; init; }
public TagType TagType { get; init; }
public SourceDto? Source { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class PersonDto : BaseDto<uint>
{
public required string Name { get; init; }
public string? ExternalUrl { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class SourceDto : BaseDto<uint>
{
public required string Name { get; init; }
public required string Key { get; init; }
public required string Url { get; init; }
}

View File

@@ -1,9 +1,11 @@
using System.ComponentModel.DataAnnotations.Schema;
using FictionArchive.Service.NovelService.Models.Images; using FictionArchive.Service.NovelService.Models.Images;
using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.Shared.Models; using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.NovelService.Models.Novels; namespace FictionArchive.Service.NovelService.Models.Novels;
[Table("Chapter")]
public class Chapter : BaseEntity<uint> public class Chapter : BaseEntity<uint>
{ {
public uint Revision { get; set; } public uint Revision { get; set; }
@@ -15,4 +17,10 @@ public class Chapter : BaseEntity<uint>
// Images appearing in this chapter. // Images appearing in this chapter.
public List<Image> Images { get; set; } public List<Image> Images { get; set; }
#region Navigation Properties
public Novel Novel { get; set; }
#endregion
} }

View File

@@ -44,6 +44,7 @@ public class Program
#region GraphQL #region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>() builder.Services.AddDefaultGraphQl<Query, Mutation>()
.ModifyCostOptions(opt => opt.MaxFieldCost = 5000)
.AddAuthorization(); .AddAuthorization();
#endregion #endregion

View File

@@ -10,10 +10,20 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
: FictionArchiveDbContext(options, logger) : FictionArchiveDbContext(options, logger)
{ {
public DbSet<Novel> Novels { get; set; } public DbSet<Novel> Novels { get; set; }
public DbSet<Chapter> Chapters { get; set; }
public DbSet<Source> Sources { get; set; } public DbSet<Source> Sources { get; set; }
public DbSet<TranslationEngine> TranslationEngines { get; set; } public DbSet<TranslationEngine> TranslationEngines { get; set; }
public DbSet<NovelTag> Tags { get; set; } public DbSet<NovelTag> Tags { get; set; }
public DbSet<LocalizationKey> LocalizationKeys { get; set; } public DbSet<LocalizationKey> LocalizationKeys { get; set; }
public DbSet<LocalizationRequest> LocalizationRequests { get; set; } public DbSet<LocalizationRequest> LocalizationRequests { get; set; }
public DbSet<Image> Images { get; set; } public DbSet<Image> Images { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Novel>()
.HasIndex("ExternalId", "SourceId")
.IsUnique();
}
} }

View File

@@ -1,3 +1,4 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.FileService.IntegrationEvents; using FictionArchive.Service.FileService.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.Configuration;
using FictionArchive.Service.NovelService.Models.Enums; using FictionArchive.Service.NovelService.Models.Enums;
@@ -31,14 +32,241 @@ public class NovelUpdateService
_novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value; _novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value;
} }
#region Helper Methods
private async Task<Source> GetOrCreateSource(SourceDescriptor descriptor)
{
var existingSource = await _dbContext.Sources
.FirstOrDefaultAsync(s => s.Key == descriptor.Key);
if (existingSource != null)
{
return existingSource;
}
return new Source
{
Name = descriptor.Name,
Url = descriptor.Url,
Key = descriptor.Key
};
}
private Person GetOrCreateAuthor(
string authorName,
string? authorUrl,
Language rawLanguage,
Person? existingAuthor)
{
// Case 1: No existing author - create new
if (existingAuthor == null)
{
return new Person
{
Name = LocalizationKey.CreateFromText(authorName, rawLanguage),
ExternalUrl = authorUrl
};
}
// Case 2: ExternalUrl differs - create new Person
if (existingAuthor.ExternalUrl != authorUrl)
{
return new Person
{
Name = LocalizationKey.CreateFromText(authorName, rawLanguage),
ExternalUrl = authorUrl
};
}
// Case 3: Same URL - update name if different
UpdateLocalizationKey(existingAuthor.Name, authorName, rawLanguage);
return existingAuthor;
}
private static void UpdateLocalizationKey(LocalizationKey key, string newText, Language language)
{
var existingText = key.Texts.FirstOrDefault(t => t.Language == language);
if (existingText != null)
{
existingText.Text = newText;
}
else
{
key.Texts.Add(new LocalizationText
{
Language = language,
Text = newText
});
}
}
private void UpdateNovelMetadata(Novel novel, NovelMetadata metadata)
{
UpdateLocalizationKey(novel.Name, metadata.Name, metadata.RawLanguage);
UpdateLocalizationKey(novel.Description, metadata.Description, metadata.RawLanguage);
novel.RawStatus = metadata.RawStatus;
novel.Url = metadata.Url;
}
private async Task<List<NovelTag>> SynchronizeTags(
List<string> sourceTags,
List<string> systemTags,
Language rawLanguage)
{
var allTagKeys = sourceTags.Concat(systemTags).ToHashSet();
// Query existing tags from DB by Key
var existingTagsInDb = await _dbContext.Tags
.Where(t => allTagKeys.Contains(t.Key))
.ToListAsync();
var existingTagKeyMap = existingTagsInDb.ToDictionary(t => t.Key);
var result = new List<NovelTag>();
// Process source tags
foreach (var tagKey in sourceTags)
{
if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag))
{
result.Add(existingTag);
}
else
{
result.Add(new NovelTag
{
Key = tagKey,
DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage),
TagType = TagType.External
});
}
}
// Process system tags
foreach (var tagKey in systemTags)
{
if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag))
{
result.Add(existingTag);
}
else
{
result.Add(new NovelTag
{
Key = tagKey,
DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage),
TagType = TagType.System
});
}
}
return result;
}
private static List<Chapter> SynchronizeChapters(
List<ChapterMetadata> metadataChapters,
Language rawLanguage,
List<Chapter>? existingChapters)
{
existingChapters ??= new List<Chapter>();
var existingOrderSet = existingChapters.Select(c => c.Order).ToHashSet();
// Only add chapters that don't already exist (by Order)
var newChapters = metadataChapters
.Where(mc => !existingOrderSet.Contains(mc.Order))
.Select(mc => new Chapter
{
Order = mc.Order,
Url = mc.Url,
Revision = mc.Revision,
Name = LocalizationKey.CreateFromText(mc.Name, rawLanguage),
Body = new LocalizationKey
{
Texts = new List<LocalizationText>()
}
})
.ToList();
// Combine existing chapters with new ones
return existingChapters.Concat(newChapters).ToList();
}
private static (Image? image, bool shouldPublishEvent) HandleCoverImage(
ImageData? newCoverData,
Image? existingCoverImage)
{
// Case 1: No new cover image - keep existing or null
if (newCoverData == null)
{
return (existingCoverImage, false);
}
// Case 2: New cover, no existing
if (existingCoverImage == null)
{
var newImage = new Image { OriginalPath = newCoverData.Url };
return (newImage, true);
}
// Case 3: Both exist - check if URL changed
if (existingCoverImage.OriginalPath != newCoverData.Url)
{
existingCoverImage.OriginalPath = newCoverData.Url;
existingCoverImage.NewPath = null; // Reset uploaded path
return (existingCoverImage, true);
}
// Case 4: Same cover URL - no change needed
return (existingCoverImage, false);
}
private async Task<Novel> CreateNewNovel(NovelMetadata metadata, Source source)
{
var author = new Person
{
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
ExternalUrl = metadata.AuthorUrl
};
var tags = await SynchronizeTags(
metadata.SourceTags,
metadata.SystemTags,
metadata.RawLanguage);
var chapters = SynchronizeChapters(metadata.Chapters, metadata.RawLanguage, null);
var novel = new Novel
{
Author = author,
RawLanguage = metadata.RawLanguage,
Url = metadata.Url,
ExternalId = metadata.ExternalId,
CoverImage = metadata.CoverImage != null
? new Image { OriginalPath = metadata.CoverImage.Url }
: null,
Chapters = chapters,
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
RawStatus = metadata.RawStatus,
Tags = tags,
Source = source
};
_dbContext.Novels.Add(novel);
return novel;
}
#endregion
public async Task<Novel> ImportNovel(string novelUrl) public async Task<Novel> ImportNovel(string novelUrl)
{ {
// Step 1: Get metadata from source adapter
NovelMetadata? metadata = null; NovelMetadata? metadata = null;
foreach (ISourceAdapter sourceAdapter in _sourceAdapters) foreach (ISourceAdapter sourceAdapter in _sourceAdapters)
{ {
if (await sourceAdapter.CanProcessNovel(novelUrl)) if (await sourceAdapter.CanProcessNovel(novelUrl))
{ {
metadata = await sourceAdapter.GetMetadata(novelUrl); metadata = await sourceAdapter.GetMetadata(novelUrl);
break; // Stop after finding adapter
} }
} }
@@ -47,72 +275,96 @@ public class NovelUpdateService
throw new NotSupportedException("The provided novel url is currently unsupported."); throw new NotSupportedException("The provided novel url is currently unsupported.");
} }
var systemTags = metadata.SystemTags.Select(tag => new NovelTag() // Step 2: Resolve or create Source
{ var source = await GetOrCreateSource(metadata.SourceDescriptor);
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.System
});
var sourceTags = metadata.SourceTags.Select(tag => new NovelTag()
{
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.External
});
var addedNovel = _dbContext.Novels.Add(new Novel() // Step 3: Check for existing novel by ExternalId + Source.Key
var existingNovel = await _dbContext.Novels
.Include(n => n.Author)
.ThenInclude(a => a.Name)
.ThenInclude(lk => lk.Texts)
.Include(n => n.Source)
.Include(n => n.Name)
.ThenInclude(lk => lk.Texts)
.Include(n => n.Description)
.ThenInclude(lk => lk.Texts)
.Include(n => n.Tags)
.Include(n => n.Chapters)
.Include(n => n.CoverImage)
.FirstOrDefaultAsync(n =>
n.ExternalId == metadata.ExternalId &&
n.Source.Key == metadata.SourceDescriptor.Key);
Novel novel;
bool shouldPublishCoverEvent;
if (existingNovel == null)
{ {
Author = new Person() // CREATE PATH: New novel
{ novel = await CreateNewNovel(metadata, source);
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage), shouldPublishCoverEvent = novel.CoverImage != null;
ExternalUrl = metadata.AuthorUrl,
},
RawLanguage = metadata.RawLanguage,
Url = metadata.Url,
ExternalId = metadata.ExternalId,
CoverImage = metadata.CoverImage != null ? new Image()
{
OriginalPath = metadata.CoverImage.Url,
} : null,
Chapters = metadata.Chapters.Select(chapter =>
{
return new Chapter()
{
Order = chapter.Order,
Url = chapter.Url,
Revision = chapter.Revision,
Name = LocalizationKey.CreateFromText(chapter.Name, metadata.RawLanguage),
Body = new LocalizationKey()
{
Texts = new List<LocalizationText>()
} }
}; else
}).ToList(),
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
RawStatus = metadata.RawStatus,
Tags = sourceTags.Concat(systemTags).ToList(),
Source = new Source()
{ {
Name = metadata.SourceDescriptor.Name, // UPDATE PATH: Existing novel
Url = metadata.SourceDescriptor.Url, novel = existingNovel;
Key = metadata.SourceDescriptor.Key,
// Update author
novel.Author = GetOrCreateAuthor(
metadata.AuthorName,
metadata.AuthorUrl,
metadata.RawLanguage,
existingNovel.Author);
// Update metadata (Name, Description, RawStatus)
UpdateNovelMetadata(novel, metadata);
// Synchronize tags (remove old, add new, reuse existing)
novel.Tags = await SynchronizeTags(
metadata.SourceTags,
metadata.SystemTags,
metadata.RawLanguage);
// Synchronize chapters (add only)
novel.Chapters = SynchronizeChapters(
metadata.Chapters,
metadata.RawLanguage,
existingNovel.Chapters);
// Handle cover image
(novel.CoverImage, shouldPublishCoverEvent) = HandleCoverImage(
metadata.CoverImage,
existingNovel.CoverImage);
} }
});
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
// Signal request for cover image if present // Publish cover image event if needed
if (addedNovel.Entity.CoverImage != null) if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null)
{ {
await _eventBus.Publish(new FileUploadRequestCreatedEvent() await _eventBus.Publish(new FileUploadRequestCreatedEvent
{ {
RequestId = addedNovel.Entity.CoverImage.Id, RequestId = novel.CoverImage.Id,
FileData = metadata.CoverImage.Data, FileData = metadata.CoverImage.Data,
FilePath = $"Novels/{addedNovel.Entity.Id}/Images/cover.jpg" FilePath = $"Novels/{novel.Id}/Images/cover.jpg"
}); });
} }
return addedNovel.Entity; // Publish chapter pull events for chapters without body content
var chaptersNeedingPull = novel.Chapters
.Where(c => c.Body?.Texts == null || !c.Body.Texts.Any())
.ToList();
foreach (var chapter in chaptersNeedingPull)
{
await _eventBus.Publish(new ChapterPullRequestedEvent
{
NovelId = novel.Id,
ChapterNumber = chapter.Order
});
}
return novel;
} }
public async Task<Chapter> PullChapterContents(uint novelId, uint chapterNumber) public async Task<Chapter> PullChapterContents(uint novelId, uint chapterNumber)
@@ -196,6 +448,7 @@ public class NovelUpdateService
if (match != null) if (match != null)
{ {
match.Attributes["src"].Value = newUrl; match.Attributes["src"].Value = newUrl;
bodyText.Text = chapterDoc.DocumentNode.OuterHtml;
} }
} }
} }

View File

@@ -15,8 +15,8 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"OIDC": { "OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "fictionarchive-api", "ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "fictionarchive-api", "Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true, "ValidateIssuer": true,
"ValidateAudience": true, "ValidateAudience": true,

View File

@@ -69,6 +69,7 @@ public class RabbitMQEventBus : IEventBus, IHostedService
await channel.ExchangeDeclareAsync(ExchangeName, ExchangeType.Direct, await channel.ExchangeDeclareAsync(ExchangeName, ExchangeType.Direct,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, cancellationToken: cancellationToken);
await channel.QueueDeclareAsync(_options.ClientIdentifier, true, false, false, await channel.QueueDeclareAsync(_options.ClientIdentifier, true, false, false,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
var consumer = new AsyncEventingBasicConsumer(channel); var consumer = new AsyncEventingBasicConsumer(channel);

View File

@@ -1,9 +1,9 @@
using FictionArchive.Service.TranslationService.Models; using FictionArchive.Service.TranslationService.Models;
using FictionArchive.Service.TranslationService.Models.Database; using FictionArchive.Service.TranslationService.Models.DTOs;
using FictionArchive.Service.TranslationService.Services.Database; using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.TranslationEngines; using FictionArchive.Service.TranslationService.Services.TranslationEngines;
using HotChocolate.Authorization; using HotChocolate.Authorization;
using Microsoft.EntityFrameworkCore; using HotChocolate.Data;
namespace FictionArchive.Service.TranslationService.GraphQL; namespace FictionArchive.Service.TranslationService.GraphQL;
@@ -22,8 +22,20 @@ public class Query
[UseProjection] [UseProjection]
[UseFiltering] [UseFiltering]
[UseSorting] [UseSorting]
public IQueryable<TranslationRequest> GetTranslationRequests(TranslationServiceDbContext dbContext) public IQueryable<TranslationRequestDto> GetTranslationRequests(TranslationServiceDbContext dbContext)
{ {
return dbContext.TranslationRequests.AsQueryable(); return dbContext.TranslationRequests.Select(request => new TranslationRequestDto
{
Id = request.Id,
CreatedTime = request.CreatedTime,
LastUpdatedTime = request.LastUpdatedTime,
OriginalText = request.OriginalText,
TranslatedText = request.TranslatedText,
From = request.From,
To = request.To,
TranslationEngineKey = request.TranslationEngineKey,
Status = request.Status,
BilledCharacterCount = request.BilledCharacterCount
});
} }
} }

View File

@@ -0,0 +1,19 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.TranslationService.Models.Enums;
using NodaTime;
namespace FictionArchive.Service.TranslationService.Models.DTOs;
public class TranslationRequestDto
{
public Guid Id { get; init; }
public Instant CreatedTime { get; init; }
public Instant LastUpdatedTime { get; init; }
public required string OriginalText { get; init; }
public string? TranslatedText { get; init; }
public Language From { get; init; }
public Language To { get; init; }
public required string TranslationEngineKey { get; init; }
public TranslationRequestStatus Status { get; init; }
public uint BilledCharacterCount { get; init; }
}

View File

@@ -18,8 +18,8 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"OIDC": { "OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "fictionarchive-api", "ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "fictionarchive-api", "Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true, "ValidateIssuer": true,
"ValidateAudience": true, "ValidateAudience": true,

View File

@@ -1,5 +1,5 @@
using FictionArchive.Service.Shared.Constants; using FictionArchive.Service.Shared.Constants;
using FictionArchive.Service.UserService.Models.Database; using FictionArchive.Service.UserService.Models.DTOs;
using FictionArchive.Service.UserService.Services; using FictionArchive.Service.UserService.Services;
using HotChocolate.Authorization; using HotChocolate.Authorization;
@@ -8,9 +8,31 @@ namespace FictionArchive.Service.UserService.GraphQL;
public class Mutation public class Mutation
{ {
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])] [Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task<User> RegisterUser(string username, string email, string oAuthProviderId, public async Task<UserDto> RegisterUser(string username, string email, string oAuthProviderId,
string? inviterOAuthProviderId, UserManagementService userManagementService) string? inviterOAuthProviderId, UserManagementService userManagementService)
{ {
return await userManagementService.RegisterUser(username, email, oAuthProviderId, inviterOAuthProviderId); var user = await userManagementService.RegisterUser(username, email, oAuthProviderId, inviterOAuthProviderId);
return new UserDto
{
Id = user.Id,
CreatedTime = user.CreatedTime,
LastUpdatedTime = user.LastUpdatedTime,
Username = user.Username,
Email = user.Email,
Disabled = user.Disabled,
Inviter = user.Inviter != null
? new UserDto
{
Id = user.Inviter.Id,
CreatedTime = user.Inviter.CreatedTime,
LastUpdatedTime = user.Inviter.LastUpdatedTime,
Username = user.Inviter.Username,
Email = user.Inviter.Email,
Disabled = user.Inviter.Disabled,
Inviter = null // Limit recursion to one level
}
: null
};
} }
} }

View File

@@ -1,4 +1,4 @@
using FictionArchive.Service.UserService.Models.Database; using FictionArchive.Service.UserService.Models.DTOs;
using FictionArchive.Service.UserService.Services; using FictionArchive.Service.UserService.Services;
using HotChocolate.Authorization; using HotChocolate.Authorization;
@@ -7,8 +7,28 @@ namespace FictionArchive.Service.UserService.GraphQL;
public class Query public class Query
{ {
[Authorize] [Authorize]
public async Task<IQueryable<User>> GetUsers(UserManagementService userManagementService) public IQueryable<UserDto> GetUsers(UserManagementService userManagementService)
{ {
return userManagementService.GetUsers(); return userManagementService.GetUsers().Select(user => new UserDto
{
Id = user.Id,
CreatedTime = user.CreatedTime,
LastUpdatedTime = user.LastUpdatedTime,
Username = user.Username,
Email = user.Email,
Disabled = user.Disabled,
Inviter = user.Inviter != null
? new UserDto
{
Id = user.Inviter.Id,
CreatedTime = user.Inviter.CreatedTime,
LastUpdatedTime = user.Inviter.LastUpdatedTime,
Username = user.Inviter.Username,
Email = user.Inviter.Email,
Disabled = user.Inviter.Disabled,
Inviter = null // Limit recursion to one level
}
: null
});
} }
} }

View File

@@ -0,0 +1,15 @@
using NodaTime;
namespace FictionArchive.Service.UserService.Models.DTOs;
public class UserDto
{
public Guid Id { get; init; }
public Instant CreatedTime { get; init; }
public Instant LastUpdatedTime { get; init; }
public required string Username { get; init; }
public required string Email { get; init; }
// OAuthProviderId intentionally omitted for security
public bool Disabled { get; init; }
public UserDto? Inviter { get; init; }
}

View File

@@ -15,8 +15,8 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"OIDC": { "OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "fictionarchive-api", "ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "fictionarchive-api", "Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/", "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true, "ValidateIssuer": true,
"ValidateAudience": true, "ValidateAudience": true,

View File

@@ -157,6 +157,7 @@ services:
OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/ OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/
OIDC__ClientId: fictionarchive-api OIDC__ClientId: fictionarchive-api
OIDC__Audience: fictionarchive-api OIDC__Audience: fictionarchive-api
Cors__AllowedOrigin: https://fictionarchive.orfl.xyz
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s interval: 30s

View File

@@ -4,37 +4,35 @@ node_modules
# Build output # Build output
dist dist
# Environment files # Development files
.env .env
.env.local .env.local
.env.*.local .env.*.local
# IDE and editor # IDE
.vscode .vscode
.idea .idea
*.swp *.swp
*.swo *.swo
# OS
.DS_Store
Thumbs.db
# Git # Git
.git .git
.gitignore .gitignore
# Logs # Logs
*.log
npm-debug.log* npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Test coverage # Test files
*.test.*
*.spec.*
__tests__
coverage coverage
# Docker
Dockerfile
.dockerignore
docker-compose*
# Documentation # Documentation
README.md README.md
*.md CHANGELOG.md
# TypeScript build info
*.tsbuildinfo

View File

@@ -0,0 +1,12 @@
# GraphQL endpoint
PUBLIC_GRAPHQL_URI=https://localhost:7063/graphql/
# OIDC Configuration
PUBLIC_OIDC_AUTHORITY=https://auth.orfl.xyz/application/o/fiction-archive/
PUBLIC_OIDC_CLIENT_ID=ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh
PUBLIC_OIDC_REDIRECT_URI=http://localhost:4321/
PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:4321/
PUBLIC_OIDC_SCOPE=openid profile email
# Optional: Token for GraphQL codegen (for authenticated schema introspection)
# CODEGEN_TOKEN=your_token_here

24
fictionarchive-web-astro/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@@ -0,0 +1,45 @@
FROM node:20-alpine AS build
WORKDIR /app
# Build arguments for environment variables
ARG PUBLIC_GRAPHQL_URI
ARG PUBLIC_OIDC_AUTHORITY
ARG PUBLIC_OIDC_CLIENT_ID
ARG PUBLIC_OIDC_REDIRECT_URI
ARG PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI
ARG PUBLIC_OIDC_SCOPE
# Set environment variables for build
ENV PUBLIC_GRAPHQL_URI=$PUBLIC_GRAPHQL_URI
ENV PUBLIC_OIDC_AUTHORITY=$PUBLIC_OIDC_AUTHORITY
ENV PUBLIC_OIDC_CLIENT_ID=$PUBLIC_OIDC_CLIENT_ID
ENV PUBLIC_OIDC_REDIRECT_URI=$PUBLIC_OIDC_REDIRECT_URI
ENV PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI=$PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI
ENV PUBLIC_OIDC_SCOPE=$PUBLIC_OIDC_SCOPE
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
# Production runtime
FROM node:20-alpine AS runtime
WORKDIR /app
# Copy built output and production dependencies
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
# Runtime configuration
ENV HOST=0.0.0.0
ENV PORT=80
EXPOSE 80
# Start the Node.js server
CMD ["node", "./dist/server/entry.mjs"]

View File

@@ -0,0 +1,43 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'astro/config';
import svelte from '@astrojs/svelte';
import tailwindcss from '@tailwindcss/vite';
import node from '@astrojs/node';
export default defineConfig({
output: 'server', // SSR mode - use prerender = true for static pages
adapter: node({
mode: 'standalone',
}),
integrations: [
svelte(),
],
vite: {
plugins: [tailwindcss()],
},
});

View File

@@ -0,0 +1,28 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
dotenv.config();
const schema = process.env.PUBLIC_GRAPHQL_URI ?? 'https://localhost:7063/graphql/';
const authToken = process.env.CODEGEN_TOKEN;
const config: CodegenConfig = {
schema: {
[schema]: authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : {},
},
documents: 'src/**/*.graphql',
generates: {
'src/lib/graphql/__generated__/graphql.ts': {
plugins: ['typescript', 'typescript-operations', 'typed-document-node'],
config: {
avoidOptionals: { field: true },
enumsAsConst: true,
skipTypename: true,
useTypeImports: true,
},
},
},
};
export default config;

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src\\styles\\global.css",
"baseColor": "gray"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

View File

@@ -0,0 +1,35 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import astro from 'eslint-plugin-astro';
import globals from 'globals';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
...svelte.configs['flat/recommended'],
...astro.configs.recommended,
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: tseslint.parser
}
},
rules: {
// Disabled because we sanitize HTML with DOMPurify before rendering
'svelte/no-at-html-tags': 'off'
}
},
{
ignores: ['node_modules/', 'dist/', '.astro/', 'src/lib/graphql/__generated__/']
}
);

12775
fictionarchive-web-astro/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
{
"name": "fictionarchive-web-astro",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"codegen": "graphql-codegen --config codegen.ts -r dotenv/config --use-system-ca",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@astrojs/node": "^9.5.1",
"@astrojs/svelte": "^7.2.2",
"@tailwindcss/vite": "^4.1.17",
"@urql/core": "^6.0.1",
"@urql/svelte": "^5.0.0",
"astro": "^5.16.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"graphql": "^16.12.0",
"isomorphic-dompurify": "^2.33.0",
"oidc-client-ts": "^3.4.1",
"svelte": "^5.45.2",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@graphql-codegen/cli": "^6.1.0",
"@graphql-codegen/typed-document-node": "^6.1.3",
"@graphql-codegen/typescript": "^5.0.5",
"@graphql-codegen/typescript-operations": "^5.0.5",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.544.0",
"@types/dompurify": "^3.0.5",
"bits-ui": "^2.14.4",
"dotenv": "^16.6.1",
"eslint": "^9.39.1",
"eslint-plugin-astro": "^1.5.0",
"eslint-plugin-svelte": "^3.13.0",
"globals": "^16.5.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0",
"typescript-eslint": "^8.48.0"
}
}

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@@ -0,0 +1,29 @@
---
import Navbar from '../lib/components/Navbar.svelte';
import AuthInit from '../lib/components/AuthInit.svelte';
import '../styles/global.css';
interface Props {
title?: string;
}
const { title = 'FictionArchive' } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body class="min-h-screen bg-background">
<AuthInit client:load />
<Navbar client:load />
<main class="mx-auto flex max-w-6xl flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
<slot />
</main>
</body>
</html>

View File

@@ -0,0 +1,38 @@
---
import AuthInit from '../lib/components/AuthInit.svelte';
import GatedAuthDisplay from '../lib/components/GatedAuthDisplay.svelte';
import '../styles/global.css';
interface Props {
title?: string;
}
const { title = 'FictionArchive' } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body class="min-h-screen bg-background">
<AuthInit client:load />
<header class="border-b bg-white/80 backdrop-blur dark:bg-gray-900/80">
<nav class="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3 sm:px-6 lg:px-8">
<a href="/" class="flex items-center gap-2">
<span class="rounded bg-primary px-2 py-1 font-bold text-primary-foreground">FA</span>
<span class="font-semibold">FictionArchive</span>
</a>
<div class="flex-1"></div>
<GatedAuthDisplay client:load />
</nav>
</header>
<main class="mx-auto flex max-w-6xl flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
<slot />
</main>
</body>
</html>

View File

@@ -0,0 +1,119 @@
import { writable, derived } from 'svelte/store';
import type { User } from 'oidc-client-ts';
import { userManager, isOidcConfigured } from './oidcConfig';
// Stores
export const user = writable<User | null>(null);
export const isLoading = writable(true);
export const isAuthenticated = derived(user, ($user) => $user !== null);
export const isConfigured = isOidcConfigured;
// Cookie management
function setCookieFromUser(u: User) {
if (!u?.access_token) return;
const isProduction = window.location.hostname !== 'localhost';
const domain = isProduction ? '.orfl.xyz' : undefined;
const secure = isProduction;
const sameSite = isProduction ? 'None' : 'Lax';
const cookieValue = `fa_session=${u.access_token}; path=/; ${secure ? 'secure; ' : ''}samesite=${sameSite}${domain ? `; domain=${domain}` : ''}`;
document.cookie = cookieValue;
}
function clearFaSessionCookie() {
const isProduction = window.location.hostname !== 'localhost';
const domain = isProduction ? '.orfl.xyz' : undefined;
const cookieValue = `fa_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT${domain ? `; domain=${domain}` : ''}`;
document.cookie = cookieValue;
}
// Track if callback has been handled to prevent double processing
let callbackHandled = false;
export async function initAuth() {
if (!userManager) {
isLoading.set(false);
return;
}
// Handle callback if auth params are present
const url = new URL(window.location.href);
const hasAuthParams =
url.searchParams.has('code') ||
url.searchParams.has('id_token') ||
url.searchParams.has('error');
if (hasAuthParams && !callbackHandled) {
callbackHandled = true;
try {
const result = await userManager.signinRedirectCallback();
user.set(result ?? null);
if (result) {
setCookieFromUser(result);
// Reload to let server see the new cookie
const cleanUrl = `${url.origin}${url.pathname}`;
window.location.href = cleanUrl;
return;
}
} catch (e) {
console.error('Failed to complete sign-in redirect', e);
} finally {
const cleanUrl = `${url.origin}${url.pathname}`;
window.history.replaceState({}, document.title, cleanUrl);
}
}
// Load existing user
try {
const loadedUser = await userManager.getUser();
user.set(loadedUser ?? null);
if (loadedUser) {
setCookieFromUser(loadedUser);
}
} catch (e) {
console.error('Failed to load user', e);
}
isLoading.set(false);
// Event listeners
userManager.events.addUserLoaded((u) => {
user.set(u);
setCookieFromUser(u);
});
userManager.events.addUserUnloaded(() => {
user.set(null);
clearFaSessionCookie();
});
userManager.events.addUserSignedOut(() => {
user.set(null);
clearFaSessionCookie();
});
}
export async function login() {
if (!userManager) {
console.warn('OIDC is not configured; set PUBLIC_OIDC_* environment variables.');
return;
}
await userManager.signinRedirect();
}
export async function logout() {
if (!userManager) {
console.warn('OIDC is not configured; set PUBLIC_OIDC_* environment variables.');
return;
}
try {
clearFaSessionCookie();
await userManager.signoutRedirect();
} catch (error) {
console.error('Failed to sign out via redirect, clearing local session instead.', error);
await userManager.removeUser();
user.set(null);
}
}

View File

@@ -1,17 +1,16 @@
import { UserManager, WebStorageStateStore, type UserManagerSettings } from 'oidc-client-ts' import { UserManager, WebStorageStateStore, type UserManagerSettings } from 'oidc-client-ts';
const authority = import.meta.env.VITE_OIDC_AUTHORITY const authority = import.meta.env.PUBLIC_OIDC_AUTHORITY;
const clientId = import.meta.env.VITE_OIDC_CLIENT_ID const clientId = import.meta.env.PUBLIC_OIDC_CLIENT_ID;
const redirectUri = import.meta.env.VITE_OIDC_REDIRECT_URI const redirectUri = import.meta.env.PUBLIC_OIDC_REDIRECT_URI;
const postLogoutRedirectUri = const postLogoutRedirectUri = import.meta.env.PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI ?? redirectUri;
import.meta.env.VITE_OIDC_POST_LOGOUT_REDIRECT_URI ?? redirectUri const scope = import.meta.env.PUBLIC_OIDC_SCOPE ?? 'openid profile email';
const scope = import.meta.env.VITE_OIDC_SCOPE ?? 'openid profile email'
export const isOidcConfigured = export const isOidcConfigured =
Boolean(authority) && Boolean(clientId) && Boolean(redirectUri) Boolean(authority) && Boolean(clientId) && Boolean(redirectUri);
function buildSettings(): UserManagerSettings | null { function buildSettings(): UserManagerSettings | null {
if (!isOidcConfigured) return null if (!isOidcConfigured) return null;
return { return {
authority: authority!, authority: authority!,
@@ -26,11 +25,11 @@ function buildSettings(): UserManagerSettings | null {
typeof window !== 'undefined' typeof window !== 'undefined'
? new WebStorageStateStore({ store: window.localStorage }) ? new WebStorageStateStore({ store: window.localStorage })
: undefined, : undefined,
} };
} }
export const userManager = (() => { export const userManager = (() => {
const settings = buildSettings() const settings = buildSettings();
if (!settings) return null if (!settings) return null;
return new UserManager(settings) return new UserManager(settings);
})() })();

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initAuth } from '$lib/auth/authStore';
onMount(() => {
initAuth();
});
</script>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { user, isLoading, isConfigured, login, logout } from '$lib/auth/authStore';
import { Button } from '$lib/components/ui/button';
let isOpen = $state(false);
const name = $derived(
$user?.profile?.name ??
$user?.profile?.preferred_username ??
$user?.profile?.email ??
$user?.profile?.sub ??
'User'
);
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.auth-dropdown')) {
isOpen = false;
}
}
function toggleDropdown() {
isOpen = !isOpen;
}
async function handleLogout() {
isOpen = false;
await logout();
}
</script>
<svelte:window onclick={handleClickOutside} />
{#if $isLoading}
<Button variant="outline" disabled>Loading...</Button>
{:else if !isConfigured}
<span class="text-sm text-yellow-600">Auth not configured</span>
{:else if $user}
<div class="auth-dropdown relative">
<Button variant="outline" onclick={toggleDropdown}>
{name}
</Button>
{#if isOpen}
<div
class="absolute right-0 z-50 mt-2 w-48 rounded-md bg-white p-2 shadow-lg dark:bg-gray-800"
>
<Button variant="ghost" class="w-full justify-start" onclick={handleLogout}>
Sign out
</Button>
</div>
{/if}
</div>
{:else}
<Button onclick={login}>Sign in</Button>
{/if}

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import List from '@lucide/svelte/icons/list';
interface Props {
novelId: string;
prevChapterOrder: number | null | undefined;
nextChapterOrder: number | null | undefined;
showKeyboardHints?: boolean;
}
let { novelId, prevChapterOrder, nextChapterOrder, showKeyboardHints = true }: Props = $props();
const hasPrev = $derived(prevChapterOrder != null);
const hasNext = $derived(nextChapterOrder != null);
</script>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-4">
<Button
variant="outline"
href={hasPrev ? `/novels/${novelId}/chapters/${prevChapterOrder}` : undefined}
disabled={!hasPrev}
class="gap-2"
>
<ChevronLeft class="h-4 w-4" />
<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>
<Button
variant="outline"
href={hasNext ? `/novels/${novelId}/chapters/${nextChapterOrder}` : undefined}
disabled={!hasNext}
class="gap-2"
>
<span class="hidden sm:inline">Next</span>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
{#if showKeyboardHints}
<p class="text-muted-foreground hidden text-center text-xs md:block">
Use <kbd class="bg-muted rounded px-1 py-0.5 text-xs"></kbd> and
<kbd class="bg-muted rounded px-1 py-0.5 text-xs"></kbd> arrow keys to navigate
</p>
{/if}
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
interface Props {
progress: number;
}
let { progress }: Props = $props();
</script>
<div
class="fixed top-0 left-0 right-0 z-50 h-1 bg-muted"
role="progressbar"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Reading progress"
>
<div
class="h-full bg-primary transition-[width] duration-150 ease-out"
style="width: {progress}%"
></div>
</div>

View File

@@ -0,0 +1,176 @@
<script lang="ts" module>
import type { GetChapterQuery } from '$lib/graphql/__generated__/graphql';
export type ChapterData = NonNullable<GetChapterQuery['chapter']>;
</script>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { client } from '$lib/graphql/client';
import { GetChapterDocument } 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';
import ChapterProgressBar from './ChapterProgressBar.svelte';
import { sanitizeChapterHtml } from '$lib/utils/sanitizeChapter';
interface Props {
novelId?: string;
chapterNumber?: string;
}
let { novelId, chapterNumber }: Props = $props();
// State
let chapter: ChapterData | null = $state(null);
let fetching = $state(true);
let error: string | null = $state(null);
let scrollProgress = $state(0);
// Derived values
const sanitizedBody = $derived(chapter?.body ? sanitizeChapterHtml(chapter.body) : '');
function handleScroll() {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
scrollProgress = docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0;
}
function handleKeydown(event: KeyboardEvent) {
// Don't trigger if user is typing in an input
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return;
}
if (event.key === 'ArrowLeft' && chapter?.prevChapterOrder != null) {
window.location.href = `/novels/${novelId}/chapters/${chapter.prevChapterOrder}`;
} else if (event.key === 'ArrowRight' && chapter?.nextChapterOrder != null) {
window.location.href = `/novels/${novelId}/chapters/${chapter.nextChapterOrder}`;
}
}
async function fetchChapter() {
if (!novelId || !chapterNumber) {
error = 'Missing novel ID or chapter number';
fetching = false;
return;
}
fetching = true;
error = null;
try {
const result = await client
.query(GetChapterDocument, {
novelId: parseInt(novelId, 10),
chapterOrder: parseInt(chapterNumber, 10)
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.chapter) {
chapter = result.data.chapter;
} else {
error = 'Chapter not found';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
}
}
onMount(() => {
fetchChapter();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('keydown', handleKeydown);
});
onDestroy(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('keydown', handleKeydown);
}
});
</script>
<ChapterProgressBar progress={scrollProgress} />
<div class="space-y-6 pt-2">
<!-- Loading State -->
{#if fetching}
<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 chapter"
></div>
</div>
</CardContent>
</Card>
{/if}
<!-- Error State -->
{#if error && !fetching}
<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 === 'Chapter not found' ? 'Chapter Not Found' : 'Error Loading Chapter'}
</p>
<p class="text-muted-foreground mt-2 text-sm">{error}</p>
<Button variant="outline" onclick={fetchChapter} class="mt-4"> Try Again </Button>
</div>
</CardContent>
</Card>
{/if}
<!-- Chapter Content -->
{#if chapter && !fetching}
<!-- Navigation (top) -->
<ChapterNavigation
novelId={novelId ?? ''}
prevChapterOrder={chapter.prevChapterOrder}
nextChapterOrder={chapter.nextChapterOrder}
/>
<!-- Chapter Header -->
<Card>
<CardContent class="py-6">
<div class="space-y-2 text-center">
<p class="text-muted-foreground text-sm">
{chapter.novelName}
</p>
<h1 class="text-2xl font-bold">Chapter {chapter.order}: {chapter.name}</h1>
</div>
</CardContent>
</Card>
<!-- Chapter Body -->
<Card>
<CardContent class="px-6 py-8 md:px-12">
<article
class="prose prose-lg dark:prose-invert mx-auto max-w-none whitespace-pre-line
prose-p:text-foreground prose-p:mb-4 prose-p:leading-relaxed
prose-headings:text-foreground
first:prose-p:mt-0 last:prose-p:mb-0"
>
{@html sanitizedBody}
</article>
</CardContent>
</Card>
<!-- Navigation (bottom) -->
<ChapterNavigation
novelId={novelId ?? ''}
prevChapterOrder={chapter.prevChapterOrder}
nextChapterOrder={chapter.nextChapterOrder}
showKeyboardHints={false}
/>
{/if}
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { isLoading, isConfigured, login } from '$lib/auth/authStore';
import { Button } from '$lib/components/ui/button';
</script>
{#if $isLoading}
<Button variant="outline" disabled>Loading...</Button>
{:else if !isConfigured}
<span class="text-sm text-yellow-600">Auth not configured</span>
{:else}
<Button onclick={login}>Sign in</Button>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { user, isLoading } from '$lib/auth/authStore';
const greeting = $derived.by(() => {
if ($isLoading) return 'Welcome to FictionArchive';
if ($user) {
const name = $user.profile?.name || $user.profile?.preferred_username;
return name ? `Welcome back, ${name}` : 'Welcome back';
}
return 'Welcome to FictionArchive';
});
</script>
<section class="py-8 text-center sm:py-12">
<h1 class="text-3xl font-bold tracking-tight sm:text-4xl">
{greeting}
</h1>
<p class="mt-2 text-lg text-muted-foreground">
Your personal fiction library
</p>
</section>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
// Direct imports for faster Astro builds
import BookOpen from '@lucide/svelte/icons/book-open';
import List from '@lucide/svelte/icons/list';
import Sparkles from '@lucide/svelte/icons/sparkles';
import HeroSection from './HeroSection.svelte';
import NavigationCard from './NavigationCard.svelte';
import RecentlyUpdatedSection from './RecentlyUpdatedSection.svelte';
</script>
<div class="flex flex-col gap-8">
<HeroSection />
<nav class="mx-auto flex w-full max-w-3xl flex-col gap-4">
<NavigationCard
href="/novels"
icon={BookOpen}
title="Novels"
description="Explore and read archived novels."
/>
<NavigationCard
href="/lists"
icon={List}
title="Reading Lists"
description="Organize stories into custom collections."
disabled
/>
<NavigationCard
href="/recommendations"
icon={Sparkles}
title="Recommendations"
description="Get suggestions based on your reading."
disabled
/>
</nav>
<RecentlyUpdatedSection />
</div>

View File

@@ -0,0 +1,141 @@
<script lang="ts">
import { client } from '$lib/graphql/client';
import { ImportNovelDocument } from '$lib/graphql/__generated__/graphql';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
interface Props {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
let { open = $bindable(), onClose, onSuccess }: Props = $props();
let novelUrl = $state('');
let submitting = $state(false);
let error: string | null = $state(null);
let success = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!novelUrl.trim()) {
error = 'Please enter a URL';
return;
}
submitting = true;
error = null;
try {
const result = await client
.mutation(ImportNovelDocument, { input: { novelUrl: novelUrl.trim() } })
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.importNovel?.novelUpdateRequestedEvent) {
success = true;
setTimeout(() => {
handleClose();
onSuccess?.();
}, 1500);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error occurred';
} finally {
submitting = false;
}
}
function handleClose() {
novelUrl = '';
error = null;
success = false;
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
handleClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
handleClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="import-modal-title"
tabindex="-1"
>
<Card class="w-full max-w-md mx-4 shadow-xl">
<CardHeader>
<CardTitle id="import-modal-title">Import Novel</CardTitle>
<p class="text-muted-foreground text-sm">
Enter the URL of the novel you want to import
</p>
</CardHeader>
<CardContent>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<label for="novel-url" class="text-sm font-medium">Novel URL</label>
<Input
id="novel-url"
type="url"
placeholder="https://example.com/novel/..."
bind:value={novelUrl}
disabled={submitting || success}
/>
</div>
{#if error}
<p class="text-destructive text-sm">{error}</p>
{/if}
{#if success}
<p class="text-green-600 dark:text-green-400 text-sm">
Import request submitted successfully!
</p>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onclick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" disabled={submitting || success || !novelUrl.trim()}>
{#if submitting}
Importing...
{:else if success}
Done
{:else}
Import
{/if}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
{/if}

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import * as NavigationMenu from '$lib/components/ui/navigation-menu';
import AuthenticationDisplay from './AuthenticationDisplay.svelte';
let pathname = $state(typeof window !== 'undefined' ? window.location.pathname : '/');
function isActive(href: string): boolean {
if (href === '/') {
return pathname === '/';
}
return pathname.startsWith(href);
}
</script>
<header class="sticky top-0 z-10 border-b bg-white/80 backdrop-blur dark:bg-gray-900/80">
<nav class="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3">
<a href="/" class="flex items-center gap-2">
<span class="rounded bg-primary px-2 py-1 font-bold text-white">FA</span>
<span class="font-semibold">FictionArchive</span>
</a>
<NavigationMenu.Root viewport={false}>
<NavigationMenu.List>
<NavigationMenu.Item>
<NavigationMenu.Link href="/novels" active={isActive('/novels')}>Novels</NavigationMenu.Link>
</NavigationMenu.Item>
</NavigationMenu.List>
</NavigationMenu.Root>
<div class="flex-1"></div>
<Input type="search" placeholder="Search..." class="max-w-xs" />
<AuthenticationDisplay />
</nav>
</header>

View File

@@ -0,0 +1,80 @@
<script lang="ts" module>
import type { Component } from 'svelte';
export interface NavigationCardProps {
href: string;
icon: Component<{ class?: string }>;
title: string;
description: string;
disabled?: boolean;
class?: string;
}
</script>
<script lang="ts">
import { cn } from '$lib/utils';
let {
href,
icon: Icon,
title,
description,
disabled = false,
class: className
}: NavigationCardProps = $props();
</script>
{#if disabled}
<div
class={cn(
'flex w-full items-center gap-4 rounded-2xl border bg-card px-6 py-5',
'cursor-not-allowed opacity-50',
className
)}
>
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-muted">
<Icon class="h-6 w-6 text-muted-foreground" />
</div>
<div class="flex flex-col gap-1">
<span class="text-xl font-semibold">{title}</span>
<span class="text-sm text-muted-foreground">{description}</span>
</div>
<span
class="ml-auto rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground"
>
Coming soon
</span>
</div>
{:else}
<a
{href}
class={cn(
'group flex w-full items-center gap-4 rounded-2xl border bg-card px-6 py-5',
'shadow-sm transition-all duration-200',
'hover:shadow-lg hover:border-primary/20',
className
)}
>
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 transition-colors group-hover:bg-primary/20"
>
<Icon class="h-6 w-6 text-primary" />
</div>
<div class="flex flex-col gap-1">
<span class="text-xl font-semibold">{title}</span>
<span class="text-sm text-muted-foreground">{description}</span>
</div>
<svg
class="ml-auto h-5 w-5 text-muted-foreground transition-transform group-hover:translate-x-1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
</a>
{/if}

View File

@@ -0,0 +1,113 @@
<script lang="ts" module>
import type { NovelsQuery, NovelStatus } from '$lib/graphql/__generated__/graphql';
export type NovelNode = NonNullable<NonNullable<NovelsQuery['novels']>['edges']>[number]['node'];
export interface NovelCardProps {
novel: NovelNode;
}
const statusColors: Record<NovelStatus, string> = {
IN_PROGRESS: 'bg-green-500 text-white',
COMPLETED: 'bg-blue-500 text-white',
HIATUS: 'bg-amber-500 text-white',
ABANDONED: 'bg-gray-500 text-white',
UNKNOWN: 'bg-gray-500 text-white'
};
const statusLabels: Record<NovelStatus, string> = {
IN_PROGRESS: 'Ongoing',
COMPLETED: 'Complete',
HIATUS: 'Hiatus',
ABANDONED: 'Dropped',
UNKNOWN: 'Unknown'
};
</script>
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider
} from '$lib/components/ui/tooltip';
import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time';
import { sanitizeHtml } from '$lib/utils/sanitize';
let { novel }: NovelCardProps = $props();
const title = $derived(novel.name || 'Untitled');
const descriptionRaw = $derived(novel.description || 'No description available.');
const descriptionHtml = $derived(sanitizeHtml(descriptionRaw));
const coverSrc = $derived(novel.coverImage?.newPath ?? novel.coverImage?.originalPath);
const latestChapter = $derived(
novel.chapters?.slice().sort((a, b) => b.order - a.order)[0] ?? null
);
const chapterDisplay = $derived(latestChapter ? `Ch. ${latestChapter.order}` : null);
const lastUpdated = $derived(novel.lastUpdatedTime ? new Date(novel.lastUpdatedTime) : null);
const relativeTime = $derived(lastUpdated ? formatRelativeTime(lastUpdated) : null);
const absoluteTime = $derived(lastUpdated ? formatAbsoluteTime(lastUpdated) : null);
const status = $derived(novel.rawStatus ?? 'UNKNOWN');
const statusColor = $derived(statusColors[status]);
const statusLabel = $derived(statusLabels[status]);
</script>
<a
href="/novels/{novel.id}"
class="block focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg"
>
<Card class="overflow-hidden border shadow-sm transition-shadow hover:shadow-md h-full pt-0 gap-0">
<div class="relative">
{#if coverSrc}
<div class="aspect-[3/4] w-full overflow-hidden bg-muted/50">
<img src={coverSrc} alt={title} class="h-full w-full object-cover" loading="lazy" />
</div>
{:else}
<div class="aspect-[3/4] w-full bg-muted/50"></div>
{/if}
<Badge
class="absolute top-2 right-2 {statusColor} shadow-sm"
aria-label="Status: {statusLabel}"
>
{statusLabel}
</Badge>
</div>
<CardHeader class="space-y-2 pt-4">
<CardTitle class="line-clamp-2 text-lg leading-tight" title={title}>
{title}
</CardTitle>
</CardHeader>
<CardContent class="pt-0 pb-4 space-y-3">
<div class="line-clamp-3 text-sm text-muted-foreground" title={descriptionRaw}>
{@html descriptionHtml}
</div>
{#if chapterDisplay || relativeTime}
<div class="flex items-center gap-1 text-xs text-muted-foreground/80">
{#if chapterDisplay}
<span>{chapterDisplay}</span>
{/if}
{#if chapterDisplay && relativeTime}
<span aria-hidden="true">·</span>
{/if}
{#if relativeTime && absoluteTime}
<TooltipProvider>
<Tooltip>
<TooltipTrigger class="cursor-default hover:text-foreground transition-colors">
<time datetime={lastUpdated?.toISOString()}>{relativeTime}</time>
</TooltipTrigger>
<TooltipContent>
{absoluteTime}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/if}
</div>
{/if}
</CardContent>
</Card>
</a>

View File

@@ -0,0 +1,438 @@
<script lang="ts" module>
import type { NovelQuery, NovelStatus, Language } from '$lib/graphql/__generated__/graphql';
export type NovelNode = NonNullable<NonNullable<NovelQuery['novels']>['nodes']>[number];
const statusColors: Record<NovelStatus, string> = {
IN_PROGRESS: 'bg-green-500 text-white',
COMPLETED: 'bg-blue-500 text-white',
HIATUS: 'bg-amber-500 text-white',
ABANDONED: 'bg-gray-500 text-white',
UNKNOWN: 'bg-gray-500 text-white'
};
const statusLabels: Record<NovelStatus, string> = {
IN_PROGRESS: 'Ongoing',
COMPLETED: 'Complete',
HIATUS: 'Hiatus',
ABANDONED: 'Dropped',
UNKNOWN: 'Unknown'
};
const languageLabels: Record<Language, string> = {
EN: 'English',
KR: 'Korean',
JA: 'Japanese',
CH: 'Chinese'
};
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { client } from '$lib/graphql/client';
import { NovelDocument, ImportNovelDocument } 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';
import { Button } from '$lib/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '$lib/components/ui/tabs';
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider
} from '$lib/components/ui/tooltip';
import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time';
import { sanitizeHtml } from '$lib/utils/sanitize';
// Direct imports for faster builds
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ExternalLink from '@lucide/svelte/icons/external-link';
import BookOpen from '@lucide/svelte/icons/book-open';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import ChevronUp from '@lucide/svelte/icons/chevron-up';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
interface Props {
novelId?: string;
}
let { novelId }: Props = $props();
let novel: NovelNode | null = $state(null);
let fetching = $state(true);
let error: string | null = $state(null);
let descriptionExpanded = $state(false);
let refreshing = $state(false);
let refreshError: string | null = $state(null);
let refreshSuccess = $state(false);
const DESCRIPTION_PREVIEW_LENGTH = 300;
// Derived values
const coverSrc = $derived(novel?.coverImage?.newPath ?? novel?.coverImage?.originalPath);
const status = $derived(novel?.rawStatus ?? 'UNKNOWN');
const statusColor = $derived(statusColors[status]);
const statusLabel = $derived(statusLabels[status]);
const language = $derived(novel?.rawLanguage ?? 'EN');
const languageLabel = $derived(languageLabels[language]);
const lastUpdated = $derived(novel?.lastUpdatedTime ? new Date(novel.lastUpdatedTime) : null);
const relativeTime = $derived(lastUpdated ? formatRelativeTime(lastUpdated) : null);
const absoluteTime = $derived(lastUpdated ? formatAbsoluteTime(lastUpdated) : null);
const description = $derived(novel?.description ?? '');
const descriptionHtml = $derived(sanitizeHtml(description));
const isDescriptionLong = $derived(description.length > DESCRIPTION_PREVIEW_LENGTH);
const truncatedDescriptionHtml = $derived(
isDescriptionLong && !descriptionExpanded
? sanitizeHtml(description.slice(0, DESCRIPTION_PREVIEW_LENGTH) + '...')
: descriptionHtml
);
const sortedChapters = $derived(
[...(novel?.chapters ?? [])].sort((a, b) => a.order - b.order)
);
const chapterCount = $derived(novel?.chapters?.length ?? 0);
const canRefresh = $derived(() => {
if (status === 'COMPLETED') return false;
if (!lastUpdated) return true;
const sixHoursAgo = Date.now() - 6 * 60 * 60 * 1000;
return lastUpdated.getTime() < sixHoursAgo;
});
async function fetchNovel() {
if (!novelId) {
error = 'No novel ID provided';
fetching = false;
return;
}
fetching = true;
error = null;
try {
const result = await client
.query(NovelDocument, { id: parseInt(novelId, 10) })
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
const nodes = result.data?.novels?.nodes;
if (nodes && nodes.length > 0) {
novel = nodes[0];
} else {
error = 'Novel not found';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
}
}
async function refreshNovel() {
if (!novel?.url) return;
refreshing = true;
refreshError = null;
refreshSuccess = false;
try {
const result = await client
.mutation(ImportNovelDocument, { input: { novelUrl: novel.url } })
.toPromise();
if (result.error) {
refreshError = result.error.message;
} else {
refreshSuccess = true;
setTimeout(() => (refreshSuccess = false), 2000);
}
} catch (e) {
refreshError = e instanceof Error ? e.message : 'Failed to refresh';
} finally {
refreshing = false;
}
}
onMount(() => {
fetchNovel();
});
</script>
<div class="space-y-6">
<!-- Back Navigation -->
<Button variant="ghost" href="/novels" class="gap-2 -ml-2">
<ArrowLeft class="h-4 w-4" />
Back to Novels
</Button>
<!-- Loading State -->
{#if fetching}
<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 novel"
></div>
</div>
</CardContent>
</Card>
{/if}
<!-- Error State -->
{#if error && !fetching}
<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 === 'Novel not found' ? 'Novel Not Found' : 'Error Loading Novel'}
</p>
<p class="text-muted-foreground mt-2 text-sm">{error}</p>
<Button variant="outline" onclick={fetchNovel} class="mt-4">
Try Again
</Button>
</div>
</CardContent>
</Card>
{/if}
<!-- Novel Content -->
{#if novel && !fetching}
<!-- Header Section (Metadata + Tags + Description) -->
<Card class="shadow-md shadow-primary/10 overflow-hidden">
<CardContent class="p-0">
<!-- Cover Image + Metadata + Tags -->
<div class="flex flex-col sm:flex-row gap-6 p-6 pb-4">
<!-- Cover Image -->
<div class="shrink-0 sm:w-40">
{#if coverSrc}
<div class="aspect-[3/4] w-full overflow-hidden rounded-lg bg-muted/50">
<img
src={coverSrc}
alt={novel.name}
class="h-full w-full object-cover"
/>
</div>
{:else}
<div class="aspect-[3/4] w-full rounded-lg bg-muted/50 flex items-center justify-center">
<BookOpen class="h-12 w-12 text-muted-foreground/50" />
</div>
{/if}
</div>
<!-- Metadata + Tags -->
<div class="flex-1 space-y-3">
<div>
<h1 class="text-2xl font-bold leading-tight">{novel.name}</h1>
{#if novel.author}
<p class="text-muted-foreground mt-1">
by
<a
href="/novels?author={encodeURIComponent(novel.author.name)}"
class="text-primary hover:underline"
>
{novel.author.name}
</a>
</p>
{/if}
</div>
<!-- Badges -->
<div class="flex flex-wrap gap-2 items-center">
<Badge class={statusColor}>{statusLabel}</Badge>
<Badge variant="outline">{languageLabel}</Badge>
{#if $isAuthenticated}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onclick={refreshNovel}
disabled={refreshing || !canRefresh()}
class="gap-1.5 h-6 text-xs"
>
<RefreshCw class="h-3 w-3 {refreshing ? 'animate-spin' : ''}" />
{refreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</TooltipTrigger>
{#if !canRefresh()}
<TooltipContent>
{status === 'COMPLETED' ? 'Cannot refresh completed novels' : 'Updated less than 6 hours ago'}
</TooltipContent>
{/if}
</Tooltip>
</TooltipProvider>
{/if}
{#if refreshSuccess}
<Badge variant="outline" class="bg-green-500/10 text-green-600 border-green-500/30">
Refresh queued
</Badge>
{/if}
{#if refreshError}
<Badge variant="outline" class="bg-destructive/10 text-destructive border-destructive/30">
{refreshError}
</Badge>
{/if}
</div>
<!-- Stats (inline) -->
<div class="text-sm text-muted-foreground flex flex-wrap gap-x-4 gap-y-1">
{#if novel.source}
<span>
Source:
<a
href={novel.url}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline inline-flex items-center gap-1"
>
{novel.source.name}
<ExternalLink class="h-3 w-3" />
</a>
</span>
{/if}
{#if relativeTime && absoluteTime}
<span>
Updated:
<TooltipProvider>
<Tooltip>
<TooltipTrigger class="cursor-default hover:text-foreground transition-colors">
<time datetime={lastUpdated?.toISOString()}>{relativeTime}</time>
</TooltipTrigger>
<TooltipContent>{absoluteTime}</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
{/if}
<span>{chapterCount} chapters</span>
</div>
<!-- Tags -->
{#if novel.tags && novel.tags.length > 0}
<div class="flex flex-wrap gap-1.5 pt-1">
{#each novel.tags as tag (tag.key)}
<Badge
variant="secondary"
href="/novels?tags={tag.key}"
class="cursor-pointer hover:bg-secondary/80 transition-colors text-xs"
>
{tag.displayName}
</Badge>
{/each}
</div>
{/if}
</div>
</div>
<!-- Description (full width below) -->
{#if description}
<div class="border-t px-6 py-4">
<div class="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
{@html truncatedDescriptionHtml}
</div>
{#if isDescriptionLong}
<Button
variant="ghost"
size="sm"
onclick={() => (descriptionExpanded = !descriptionExpanded)}
class="mt-2 gap-1 -ml-2"
>
{#if descriptionExpanded}
<ChevronUp class="h-4 w-4" />
Show less
{:else}
<ChevronDown class="h-4 w-4" />
Show more
{/if}
</Button>
{/if}
</div>
{/if}
</CardContent>
</Card>
<!-- Tabbed Content -->
<Card>
<Tabs value="chapters" class="w-full">
<CardHeader class="pb-0">
<TabsList class="grid w-full grid-cols-3 bg-muted/50 p-1 rounded-lg">
<TabsTrigger
value="chapters"
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all"
>
Chapters
</TabsTrigger>
<TabsTrigger
value="comments"
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"
>
Comments
</TabsTrigger>
<TabsTrigger
value="recommendations"
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"
>
Recommendations
</TabsTrigger>
</TabsList>
</CardHeader>
<CardContent class="pt-4">
<TabsContent value="chapters" class="mt-0">
{#if sortedChapters.length === 0}
<p class="text-muted-foreground text-sm py-4 text-center">
No chapters available yet.
</p>
{:else}
<div class="max-h-96 overflow-y-auto -mx-2">
{#each sortedChapters as chapter (chapter.id)}
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
<a
href="/novels/{novelId}/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">
<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 chapterDate}
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
{formatRelativeTime(chapterDate)}
</span>
{/if}
</a>
{/each}
</div>
{/if}
</TabsContent>
<TabsContent value="comments" class="mt-0">
<p class="text-muted-foreground text-sm py-8 text-center">
Comments coming soon.
</p>
</TabsContent>
<TabsContent value="recommendations" class="mt-0">
<p class="text-muted-foreground text-sm py-8 text-center">
Recommendations coming soon.
</p>
</TabsContent>
</CardContent>
</Tabs>
</Card>
{/if}
</div>

View File

@@ -0,0 +1,266 @@
<script lang="ts">
import { Select } from 'bits-ui';
// Direct imports for faster Astro builds
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Check from '@lucide/svelte/icons/check';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { type NovelFilters, hasActiveFilters, EMPTY_FILTERS } from '$lib/utils/filterParams';
import { NovelStatus, type NovelTagDto } from '$lib/graphql/__generated__/graphql';
interface Props {
filters: NovelFilters;
onFilterChange: (filters: NovelFilters) => void;
availableTags: Pick<NovelTagDto, 'key' | 'displayName'>[];
}
let { filters, onFilterChange, availableTags }: Props = $props();
// Local state for search input (for debouncing)
let searchInput = $state(filters.search);
let authorInput = $state(filters.authorName);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let authorTimeout: ReturnType<typeof setTimeout> | null = null;
// Status options
const statusOptions: { value: NovelStatus; label: string }[] = [
{ value: NovelStatus.InProgress, label: 'In Progress' },
{ value: NovelStatus.Completed, label: 'Completed' },
{ value: NovelStatus.Hiatus, label: 'Hiatus' },
{ value: NovelStatus.Abandoned, label: 'Abandoned' },
{ value: NovelStatus.Unknown, label: 'Unknown' }
];
// Derived state for display
const selectedStatusLabels = $derived(
filters.statuses.map((s) => statusOptions.find((o) => o.value === s)?.label ?? s).join(', ')
);
const selectedTagLabels = $derived(
filters.tags.map((t) => availableTags.find((tag) => tag.key === t)?.displayName ?? t).join(', ')
);
// Debounced search handler
function handleSearchInput(value: string) {
searchInput = value;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
onFilterChange({ ...filters, search: value });
}, 300);
}
// Debounced author handler
function handleAuthorInput(value: string) {
authorInput = value;
if (authorTimeout) clearTimeout(authorTimeout);
authorTimeout = setTimeout(() => {
onFilterChange({ ...filters, authorName: value });
}, 300);
}
// Status selection handler
function handleStatusChange(selected: NovelStatus[]) {
onFilterChange({ ...filters, statuses: selected });
}
// Tag selection handler
function handleTagChange(selected: string[]) {
onFilterChange({ ...filters, tags: selected });
}
// Clear all filters
function clearFilters() {
searchInput = '';
authorInput = '';
onFilterChange({ ...EMPTY_FILTERS });
}
// Sync search input when filters prop changes externally
$effect(() => {
if (filters.search !== searchInput && !searchTimeout) {
searchInput = filters.search;
}
});
// Sync author input when filters prop changes externally
$effect(() => {
if (filters.authorName !== authorInput && !authorTimeout) {
authorInput = filters.authorName;
}
});
</script>
<div class="flex flex-wrap items-center gap-3">
<!-- Search Input -->
<div class="relative min-w-[200px] flex-1">
<Search class="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="Search novels..."
value={searchInput}
oninput={(e) => handleSearchInput(e.currentTarget.value)}
class="pl-9"
/>
</div>
<!-- Author Input -->
<div class="min-w-[150px]">
<Input
type="text"
placeholder="Author..."
value={authorInput}
oninput={(e) => handleAuthorInput(e.currentTarget.value)}
/>
</div>
<!-- Status Filter -->
<Select.Root
type="multiple"
value={filters.statuses}
onValueChange={(v) => handleStatusChange(v as NovelStatus[])}
>
<Select.Trigger
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 min-w-[140px] items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50"
>
<span class="truncate text-left">
{filters.statuses.length > 0 ? selectedStatusLabels : 'Status'}
</span>
<ChevronDown class="h-4 w-4 opacity-50" />
</Select.Trigger>
<Select.Content
class="bg-popover text-popover-foreground z-50 max-h-60 min-w-[140px] overflow-auto rounded-md border p-1 shadow-md"
>
{#each statusOptions as option (option.value)}
<Select.Item
value={option.value}
class="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
{#snippet children({ selected })}
<div
class="border-primary flex h-4 w-4 items-center justify-center rounded-sm border {selected
? 'bg-primary text-primary-foreground'
: ''}"
>
{#if selected}
<Check class="h-3 w-3" />
{/if}
</div>
<span>{option.label}</span>
{/snippet}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Tags Filter -->
{#if availableTags.length > 0}
<Select.Root
type="multiple"
value={filters.tags}
onValueChange={(v) => handleTagChange(v as string[])}
>
<Select.Trigger
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 min-w-[120px] items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50"
>
<span class="truncate text-left">
{filters.tags.length > 0 ? selectedTagLabels : 'Tags'}
</span>
<ChevronDown class="h-4 w-4 opacity-50" />
</Select.Trigger>
<Select.Content
class="bg-popover text-popover-foreground z-50 max-h-60 min-w-[180px] overflow-auto rounded-md border p-1 shadow-md"
>
{#each availableTags as tag (tag.key)}
<Select.Item
value={tag.key}
class="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
{#snippet children({ selected })}
<div
class="border-primary flex h-4 w-4 items-center justify-center rounded-sm border {selected
? 'bg-primary text-primary-foreground'
: ''}"
>
{#if selected}
<Check class="h-3 w-3" />
{/if}
</div>
<span>{tag.displayName}</span>
{/snippet}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<!-- Clear Filters Button -->
{#if hasActiveFilters(filters)}
<Button variant="outline" size="sm" onclick={clearFilters} class="gap-1">
<X class="h-4 w-4" />
Clear
</Button>
{/if}
</div>
<!-- Active Filter Badges -->
{#if hasActiveFilters(filters)}
<div class="mt-3 flex flex-wrap gap-2">
{#if filters.search}
<Badge variant="secondary" class="gap-1">
Search: {filters.search}
<button
onclick={() => {
searchInput = '';
onFilterChange({ ...filters, search: '' });
}}
class="hover:bg-secondary-foreground/20 ml-1 rounded-full p-0.5"
>
<X class="h-3 w-3" />
</button>
</Badge>
{/if}
{#if filters.authorName}
<Badge variant="secondary" class="gap-1">
Author: {filters.authorName}
<button
onclick={() => {
authorInput = '';
onFilterChange({ ...filters, authorName: '' });
}}
class="hover:bg-secondary-foreground/20 ml-1 rounded-full p-0.5"
>
<X class="h-3 w-3" />
</button>
</Badge>
{/if}
{#each filters.statuses as status (status)}
<Badge variant="secondary" class="gap-1">
{statusOptions.find((o) => o.value === status)?.label ?? status}
<button
onclick={() =>
onFilterChange({ ...filters, statuses: filters.statuses.filter((s) => s !== status) })}
class="hover:bg-secondary-foreground/20 ml-1 rounded-full p-0.5"
>
<X class="h-3 w-3" />
</button>
</Badge>
{/each}
{#each filters.tags as tag (tag)}
<Badge variant="secondary" class="gap-1">
{availableTags.find((t) => t.key === tag)?.displayName ?? tag}
<button
onclick={() => onFilterChange({ ...filters, tags: filters.tags.filter((t) => t !== tag) })}
class="hover:bg-secondary-foreground/20 ml-1 rounded-full p-0.5"
>
<X class="h-3 w-3" />
</button>
</Badge>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,195 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { client } from '$lib/graphql/client';
import { NovelsDocument, type NovelsQuery, type NovelTagDto } from '$lib/graphql/__generated__/graphql';
import NovelCard from './NovelCard.svelte';
import NovelFilters from './NovelFilters.svelte';
import ImportNovelModal from './ImportNovelModal.svelte';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { isAuthenticated } from '$lib/auth/authStore';
import {
type NovelFilters as NovelFiltersType,
parseFiltersFromURL,
syncFiltersToURL,
filtersToGraphQLWhere,
hasActiveFilters,
EMPTY_FILTERS
} from '$lib/utils/filterParams';
const PAGE_SIZE = 12;
type NovelEdge = NonNullable<NovelsQuery['novels']>['edges'][number];
let edges: NovelEdge[] = $state([]);
let pageInfo: NonNullable<NovelsQuery['novels']>['pageInfo'] | null = $state(null);
let fetching = $state(false);
let error: string | null = $state(null);
let initialLoad = $state(true);
let filters: NovelFiltersType = $state({ ...EMPTY_FILTERS });
let showImportModal = $state(false);
const hasNextPage = $derived(pageInfo?.hasNextPage ?? false);
const novels = $derived(edges.map((edge) => edge.node).filter(Boolean));
// Extract unique tags from loaded novels for the tag filter dropdown
const availableTags = $derived(() => {
const tagMap = new SvelteMap<string, Pick<NovelTagDto, 'key' | 'displayName'>>();
for (const novel of novels) {
for (const tag of novel.tags ?? []) {
if (!tagMap.has(tag.key)) {
tagMap.set(tag.key, { key: tag.key, displayName: tag.displayName });
}
}
}
return Array.from(tagMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));
});
async function fetchNovels(after: string | null = null) {
fetching = true;
error = null;
try {
const where = filtersToGraphQLWhere(filters);
const result = await client
.query(NovelsDocument, { first: PAGE_SIZE, after, where })
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.novels) {
if (after) {
// Append for pagination
edges = [...edges, ...result.data.novels.edges];
} else {
// Initial load or filter change
edges = result.data.novels.edges;
}
pageInfo = result.data.novels.pageInfo;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
initialLoad = false;
}
}
function loadMore() {
if (pageInfo?.endCursor) {
fetchNovels(pageInfo.endCursor);
}
}
function handleFilterChange(newFilters: NovelFiltersType) {
filters = newFilters;
// Reset pagination and refetch
edges = [];
pageInfo = null;
syncFiltersToURL(filters);
fetchNovels();
}
onMount(() => {
// Parse filters from URL on initial load
filters = parseFiltersFromURL();
fetchNovels();
// Listen for browser back/forward navigation
const handlePopState = () => {
filters = parseFiltersFromURL();
edges = [];
pageInfo = null;
fetchNovels();
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
});
</script>
<div class="space-y-4">
<Card class="shadow-md shadow-primary/10">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Novels</CardTitle>
{#if $isAuthenticated}
<Button variant="outline" onclick={() => (showImportModal = true)}>
Import Novel
</Button>
{/if}
</div>
<p class="text-muted-foreground text-sm">
{#if hasActiveFilters(filters)}
Showing filtered results
{:else}
Browse all novels
{/if}
</p>
</CardHeader>
<CardContent>
<NovelFilters {filters} onFilterChange={handleFilterChange} availableTags={availableTags()} />
</CardContent>
</Card>
{#if fetching && initialLoad}
<Card>
<CardContent>
<div class="flex items-center justify-center py-8">
<div
class="border-primary h-10 w-10 animate-spin rounded-full border-2 border-t-transparent"
aria-label="Loading novels"
></div>
</div>
</CardContent>
</Card>
{/if}
{#if error}
<Card class="border-destructive/40 bg-destructive/5">
<CardContent>
<p class="text-destructive py-4 text-sm">Could not load novels: {error}</p>
</CardContent>
</Card>
{/if}
{#if !fetching && novels.length === 0 && !error && !initialLoad}
<Card>
<CardContent>
<p class="text-muted-foreground py-4 text-sm">
{#if hasActiveFilters(filters)}
No novels match your filters. Try adjusting your search criteria.
{:else}
No novels found yet. Try adding content to the gateway.
{/if}
</p>
</CardContent>
</Card>
{/if}
{#if novels.length > 0}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each novels as novel (novel.id)}
<NovelCard {novel} />
{/each}
</div>
{/if}
{#if hasNextPage}
<div class="flex justify-center">
<Button onclick={loadMore} variant="outline" disabled={fetching} class="min-w-[160px]">
{fetching ? 'Loading...' : 'Load more'}
</Button>
</div>
{/if}
</div>
<ImportNovelModal
bind:open={showImportModal}
onClose={() => (showImportModal = false)}
onSuccess={() => fetchNovels()}
/>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { onMount } from 'svelte';
import { client } from '$lib/graphql/client';
import { NovelsDocument, type NovelsQuery } from '$lib/graphql/__generated__/graphql';
import NovelCard from './NovelCard.svelte';
import Clock from '@lucide/svelte/icons/clock';
type NovelEdge = NonNullable<NovelsQuery['novels']>['edges'][number];
let edges: NovelEdge[] = $state([]);
let fetching = $state(true);
let error: string | null = $state(null);
const novels = $derived(edges.map((edge) => edge.node).filter(Boolean).slice(0, 5));
async function fetchRecentNovels() {
fetching = true;
error = null;
try {
const result = await client.query(NovelsDocument, { first: 5 }).toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.novels) {
edges = result.data.novels.edges;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
}
}
onMount(() => {
fetchRecentNovels();
});
</script>
<section class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Clock class="h-5 w-5 text-muted-foreground" />
<h2 class="text-xl font-semibold">Recently Updated</h2>
</div>
<a
href="/novels"
class="text-sm text-muted-foreground transition-colors hover:text-primary"
>
View all
</a>
</div>
{#if fetching}
<div class="flex items-center justify-center py-8">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"
aria-label="Loading novels"
></div>
</div>
{:else if error}
<div class="rounded-xl border border-destructive/40 bg-destructive/5 p-4">
<p class="text-sm text-destructive">Could not load novels: {error}</p>
</div>
{:else if novels.length === 0}
<div class="rounded-xl border bg-muted/50 p-4">
<p class="text-sm text-muted-foreground">No novels found yet.</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{#each novels as novel (novel.id)}
<a href="/novels/{novel.id}" class="block">
<NovelCard {novel} />
</a>
{/each}
</div>
{/if}
</section>

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
outline:
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("font-semibold leading-none", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50",
"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
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
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 h-9 w-full min-w-0 rounded-md border px-3 py-1 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
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -0,0 +1,28 @@
import Root from "./navigation-menu.svelte";
import Content from "./navigation-menu-content.svelte";
import Indicator from "./navigation-menu-indicator.svelte";
import Item from "./navigation-menu-item.svelte";
import Link from "./navigation-menu-link.svelte";
import List from "./navigation-menu-list.svelte";
import Trigger from "./navigation-menu-trigger.svelte";
import Viewport from "./navigation-menu-viewport.svelte";
export {
Root,
Content,
Indicator,
Item,
Link,
List,
Trigger,
Viewport,
//
Root as NavigationMenuRoot,
Content as NavigationMenuContent,
Indicator as NavigationMenuIndicator,
Item as NavigationMenuItem,
Link as NavigationMenuLink,
List as NavigationMenuList,
Trigger as NavigationMenuTrigger,
Viewport as NavigationMenuViewport,
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ContentProps = $props();
</script>
<NavigationMenuPrimitive.Content
bind:ref
data-slot="navigation-menu-content"
class={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-end-52 data-[motion=from-start]:slide-in-from-start-52 data-[motion=to-end]:slide-out-to-end-52 data-[motion=to-start]:slide-out-to-start-52 start-0 top-0 w-full md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.IndicatorProps = $props();
</script>
<NavigationMenuPrimitive.Indicator
bind:ref
data-slot="navigation-menu-indicator"
class={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...restProps}
>
<div class="bg-border rounded-ts-sm relative top-[60%] h-2 w-2 rotate-45 shadow-md"></div>
</NavigationMenuPrimitive.Indicator>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ItemProps = $props();
</script>
<NavigationMenuPrimitive.Item
bind:ref
data-slot="navigation-menu-item"
class={cn("relative", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.LinkProps = $props();
</script>
<NavigationMenuPrimitive.Link
bind:ref
data-slot="navigation-menu-link"
class={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm outline-none transition-all focus-visible:outline-1 focus-visible:ring-[3px] [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ListProps = $props();
</script>
<NavigationMenuPrimitive.List
bind:ref
data-slot="navigation-menu-list"
class={cn("group flex flex-1 list-none items-center justify-center gap-1", className)}
{...restProps}
/>

View File

@@ -0,0 +1,34 @@
<script lang="ts" module>
import { cn } from "$lib/utils.js";
import { tv } from "tailwind-variants";
export const navigationMenuTriggerStyle = tv({
base: "bg-background hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50",
});
</script>
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: NavigationMenuPrimitive.TriggerProps = $props();
</script>
<NavigationMenuPrimitive.Trigger
bind:ref
data-slot="navigation-menu-trigger"
class={cn(navigationMenuTriggerStyle(), "group", className)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
class="relative top-[1px] ms-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ViewportProps = $props();
</script>
<div class={cn("absolute start-0 top-full isolate z-50 flex justify-center")}>
<NavigationMenuPrimitive.Viewport
bind:ref
data-slot="navigation-menu-viewport"
class={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--bits-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--bits-navigation-menu-viewport-width)]",
className
)}
{...restProps}
/>
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import NavigationMenuViewport from "./navigation-menu-viewport.svelte";
let {
ref = $bindable(null),
class: className,
viewport = true,
children,
...restProps
}: NavigationMenuPrimitive.RootProps & {
viewport?: boolean;
} = $props();
</script>
<NavigationMenuPrimitive.Root
bind:ref
data-slot="navigation-menu"
data-viewport={viewport}
class={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...restProps}
>
{@render children?.()}
{#if viewport}
<NavigationMenuViewport />
{/if}
</NavigationMenuPrimitive.Root>

View File

@@ -0,0 +1,18 @@
import { Tabs as TabsPrimitive } from 'bits-ui';
const Root = TabsPrimitive.Root;
const List = TabsPrimitive.List;
const Trigger = TabsPrimitive.Trigger;
const Content = TabsPrimitive.Content;
export {
Root,
List,
Trigger,
Content,
//
Root as Tabs,
List as TabsList,
Trigger as TabsTrigger,
Content as TabsContent
};

View File

@@ -0,0 +1,18 @@
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import Content from './tooltip-content.svelte';
const Root = TooltipPrimitive.Root;
const Trigger = TooltipPrimitive.Trigger;
const Provider = TooltipPrimitive.Provider;
export {
Root,
Trigger,
Content,
Provider,
//
Root as Tooltip,
Trigger as TooltipTrigger,
Content as TooltipContent,
Provider as TooltipProvider
};

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
sideOffset = 4,
class: className,
...restProps
}: TooltipPrimitive.ContentProps = $props();
</script>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
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 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
className
)}
{...restProps}
/>
</TooltipPrimitive.Portal>

View File

@@ -1,6 +1,6 @@
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type Maybe<T> = T | null; export type Maybe<T> = T | null;
export type InputMaybe<T> = T | null; export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }; export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> }; export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> }; export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
@@ -18,27 +18,38 @@ export type Scalars = {
UnsignedInt: { input: any; output: any; } UnsignedInt: { input: any; output: any; }
}; };
export type Chapter = { /** Defines when a policy shall be executed. */
body: LocalizationKey; export const ApplyPolicy = {
/** After the resolver was executed. */
AfterResolver: 'AFTER_RESOLVER',
/** Before the resolver was executed. */
BeforeResolver: 'BEFORE_RESOLVER',
/** The policy is applied in the validation step before the execution. */
Validation: 'VALIDATION'
} as const;
export type ApplyPolicy = typeof ApplyPolicy[keyof typeof ApplyPolicy];
export type ChapterDto = {
body: Scalars['String']['output'];
createdTime: Scalars['Instant']['output']; createdTime: Scalars['Instant']['output'];
id: Scalars['UnsignedInt']['output']; id: Scalars['UnsignedInt']['output'];
images: Array<Image>; images: Array<ImageDto>;
lastUpdatedTime: Scalars['Instant']['output']; lastUpdatedTime: Scalars['Instant']['output'];
name: LocalizationKey; name: Scalars['String']['output'];
order: Scalars['UnsignedInt']['output']; order: Scalars['UnsignedInt']['output'];
revision: Scalars['UnsignedInt']['output']; revision: Scalars['UnsignedInt']['output'];
url: Maybe<Scalars['String']['output']>; url: Maybe<Scalars['String']['output']>;
}; };
export type ChapterFilterInput = { export type ChapterDtoFilterInput = {
and?: InputMaybe<Array<ChapterFilterInput>>; and?: InputMaybe<Array<ChapterDtoFilterInput>>;
body?: InputMaybe<LocalizationKeyFilterInput>; body?: InputMaybe<StringOperationFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>; createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>; id?: InputMaybe<UnsignedIntOperationFilterInputType>;
images?: InputMaybe<ListFilterInputTypeOfImageFilterInput>; images?: InputMaybe<ListFilterInputTypeOfImageDtoFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>; lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<LocalizationKeyFilterInput>; name?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<ChapterFilterInput>>; or?: InputMaybe<Array<ChapterDtoFilterInput>>;
order?: InputMaybe<UnsignedIntOperationFilterInputType>; order?: InputMaybe<UnsignedIntOperationFilterInputType>;
revision?: InputMaybe<UnsignedIntOperationFilterInputType>; revision?: InputMaybe<UnsignedIntOperationFilterInputType>;
url?: InputMaybe<StringOperationFilterInput>; url?: InputMaybe<StringOperationFilterInput>;
@@ -49,15 +60,21 @@ export type ChapterPullRequestedEvent = {
novelId: Scalars['UnsignedInt']['output']; novelId: Scalars['UnsignedInt']['output'];
}; };
export type ChapterSortInput = { export type ChapterReaderDto = {
body?: InputMaybe<LocalizationKeySortInput>; body: Scalars['String']['output'];
createdTime?: InputMaybe<SortEnumType>; createdTime: Scalars['Instant']['output'];
id?: InputMaybe<SortEnumType>; id: Scalars['UnsignedInt']['output'];
lastUpdatedTime?: InputMaybe<SortEnumType>; images: Array<ImageDto>;
name?: InputMaybe<LocalizationKeySortInput>; lastUpdatedTime: Scalars['Instant']['output'];
order?: InputMaybe<SortEnumType>; name: Scalars['String']['output'];
revision?: InputMaybe<SortEnumType>; nextChapterOrder: Maybe<Scalars['UnsignedInt']['output']>;
url?: InputMaybe<SortEnumType>; novelId: Scalars['UnsignedInt']['output'];
novelName: Scalars['String']['output'];
order: Scalars['UnsignedInt']['output'];
prevChapterOrder: Maybe<Scalars['UnsignedInt']['output']>;
revision: Scalars['UnsignedInt']['output'];
totalChapters: Scalars['Int']['output'];
url: Maybe<Scalars['String']['output']>;
}; };
export type DeleteJobError = KeyNotFoundError; export type DeleteJobError = KeyNotFoundError;
@@ -92,8 +109,7 @@ export type FormatError = Error & {
message: Scalars['String']['output']; message: Scalars['String']['output'];
}; };
export type Image = { export type ImageDto = {
chapter: Maybe<Chapter>;
createdTime: Scalars['Instant']['output']; createdTime: Scalars['Instant']['output'];
id: Scalars['UUID']['output']; id: Scalars['UUID']['output'];
lastUpdatedTime: Scalars['Instant']['output']; lastUpdatedTime: Scalars['Instant']['output'];
@@ -101,19 +117,17 @@ export type Image = {
originalPath: Scalars['String']['output']; originalPath: Scalars['String']['output'];
}; };
export type ImageFilterInput = { export type ImageDtoFilterInput = {
and?: InputMaybe<Array<ImageFilterInput>>; and?: InputMaybe<Array<ImageDtoFilterInput>>;
chapter?: InputMaybe<ChapterFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>; createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>; id?: InputMaybe<UuidOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>; lastUpdatedTime?: InputMaybe<InstantFilterInput>;
newPath?: InputMaybe<StringOperationFilterInput>; newPath?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<ImageFilterInput>>; or?: InputMaybe<Array<ImageDtoFilterInput>>;
originalPath?: InputMaybe<StringOperationFilterInput>; originalPath?: InputMaybe<StringOperationFilterInput>;
}; };
export type ImageSortInput = { export type ImageDtoSortInput = {
chapter?: InputMaybe<ChapterSortInput>;
createdTime?: InputMaybe<SortEnumType>; createdTime?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>; id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>; lastUpdatedTime?: InputMaybe<SortEnumType>;
@@ -167,81 +181,25 @@ export type LanguageOperationFilterInput = {
nin?: InputMaybe<Array<Language>>; nin?: InputMaybe<Array<Language>>;
}; };
export type ListFilterInputTypeOfChapterFilterInput = { export type ListFilterInputTypeOfChapterDtoFilterInput = {
all?: InputMaybe<ChapterFilterInput>; all?: InputMaybe<ChapterDtoFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>; any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<ChapterFilterInput>; none?: InputMaybe<ChapterDtoFilterInput>;
some?: InputMaybe<ChapterFilterInput>; some?: InputMaybe<ChapterDtoFilterInput>;
}; };
export type ListFilterInputTypeOfImageFilterInput = { export type ListFilterInputTypeOfImageDtoFilterInput = {
all?: InputMaybe<ImageFilterInput>; all?: InputMaybe<ImageDtoFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>; any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<ImageFilterInput>; none?: InputMaybe<ImageDtoFilterInput>;
some?: InputMaybe<ImageFilterInput>; some?: InputMaybe<ImageDtoFilterInput>;
}; };
export type ListFilterInputTypeOfLocalizationTextFilterInput = { export type ListFilterInputTypeOfNovelTagDtoFilterInput = {
all?: InputMaybe<LocalizationTextFilterInput>; all?: InputMaybe<NovelTagDtoFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>; any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<LocalizationTextFilterInput>; none?: InputMaybe<NovelTagDtoFilterInput>;
some?: InputMaybe<LocalizationTextFilterInput>; some?: InputMaybe<NovelTagDtoFilterInput>;
};
export type ListFilterInputTypeOfNovelFilterInput = {
all?: InputMaybe<NovelFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<NovelFilterInput>;
some?: InputMaybe<NovelFilterInput>;
};
export type ListFilterInputTypeOfNovelTagFilterInput = {
all?: InputMaybe<NovelTagFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<NovelTagFilterInput>;
some?: InputMaybe<NovelTagFilterInput>;
};
export type LocalizationKey = {
createdTime: Scalars['Instant']['output'];
id: Scalars['UUID']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
texts: Array<LocalizationText>;
};
export type LocalizationKeyFilterInput = {
and?: InputMaybe<Array<LocalizationKeyFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<LocalizationKeyFilterInput>>;
texts?: InputMaybe<ListFilterInputTypeOfLocalizationTextFilterInput>;
};
export type LocalizationKeySortInput = {
createdTime?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
};
export type LocalizationText = {
createdTime: Scalars['Instant']['output'];
id: Scalars['UUID']['output'];
language: Language;
lastUpdatedTime: Scalars['Instant']['output'];
text: Scalars['String']['output'];
translationEngine: Maybe<TranslationEngine>;
};
export type LocalizationTextFilterInput = {
and?: InputMaybe<Array<LocalizationTextFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>;
language?: InputMaybe<LanguageOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<LocalizationTextFilterInput>>;
text?: InputMaybe<StringOperationFilterInput>;
translationEngine?: InputMaybe<TranslationEngineFilterInput>;
}; };
export type Mutation = { export type Mutation = {
@@ -289,56 +247,56 @@ export type MutationTranslateTextArgs = {
input: TranslateTextInput; input: TranslateTextInput;
}; };
export type Novel = { export type NovelDto = {
author: Person; author: PersonDto;
chapters: Array<Chapter>; chapters: Array<ChapterDto>;
coverImage: Maybe<Image>; coverImage: Maybe<ImageDto>;
createdTime: Scalars['Instant']['output']; createdTime: Scalars['Instant']['output'];
description: LocalizationKey; description: Scalars['String']['output'];
externalId: Scalars['String']['output']; externalId: Scalars['String']['output'];
id: Scalars['UnsignedInt']['output']; id: Scalars['UnsignedInt']['output'];
lastUpdatedTime: Scalars['Instant']['output']; lastUpdatedTime: Scalars['Instant']['output'];
name: LocalizationKey; name: Scalars['String']['output'];
rawLanguage: Language; rawLanguage: Language;
rawStatus: NovelStatus; rawStatus: NovelStatus;
source: Source; source: SourceDto;
statusOverride: Maybe<NovelStatus>; statusOverride: Maybe<NovelStatus>;
tags: Array<NovelTag>; tags: Array<NovelTagDto>;
url: Scalars['String']['output']; url: Scalars['String']['output'];
}; };
export type NovelFilterInput = { export type NovelDtoFilterInput = {
and?: InputMaybe<Array<NovelFilterInput>>; and?: InputMaybe<Array<NovelDtoFilterInput>>;
author?: InputMaybe<PersonFilterInput>; author?: InputMaybe<PersonDtoFilterInput>;
chapters?: InputMaybe<ListFilterInputTypeOfChapterFilterInput>; chapters?: InputMaybe<ListFilterInputTypeOfChapterDtoFilterInput>;
coverImage?: InputMaybe<ImageFilterInput>; coverImage?: InputMaybe<ImageDtoFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>; createdTime?: InputMaybe<InstantFilterInput>;
description?: InputMaybe<LocalizationKeyFilterInput>; description?: InputMaybe<StringOperationFilterInput>;
externalId?: InputMaybe<StringOperationFilterInput>; externalId?: InputMaybe<StringOperationFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>; id?: InputMaybe<UnsignedIntOperationFilterInputType>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>; lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<LocalizationKeyFilterInput>; name?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<NovelFilterInput>>; or?: InputMaybe<Array<NovelDtoFilterInput>>;
rawLanguage?: InputMaybe<LanguageOperationFilterInput>; rawLanguage?: InputMaybe<LanguageOperationFilterInput>;
rawStatus?: InputMaybe<NovelStatusOperationFilterInput>; rawStatus?: InputMaybe<NovelStatusOperationFilterInput>;
source?: InputMaybe<SourceFilterInput>; source?: InputMaybe<SourceDtoFilterInput>;
statusOverride?: InputMaybe<NullableOfNovelStatusOperationFilterInput>; statusOverride?: InputMaybe<NullableOfNovelStatusOperationFilterInput>;
tags?: InputMaybe<ListFilterInputTypeOfNovelTagFilterInput>; tags?: InputMaybe<ListFilterInputTypeOfNovelTagDtoFilterInput>;
url?: InputMaybe<StringOperationFilterInput>; url?: InputMaybe<StringOperationFilterInput>;
}; };
export type NovelSortInput = { export type NovelDtoSortInput = {
author?: InputMaybe<PersonSortInput>; author?: InputMaybe<PersonDtoSortInput>;
coverImage?: InputMaybe<ImageSortInput>; coverImage?: InputMaybe<ImageDtoSortInput>;
createdTime?: InputMaybe<SortEnumType>; createdTime?: InputMaybe<SortEnumType>;
description?: InputMaybe<LocalizationKeySortInput>; description?: InputMaybe<SortEnumType>;
externalId?: InputMaybe<SortEnumType>; externalId?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>; id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>; lastUpdatedTime?: InputMaybe<SortEnumType>;
name?: InputMaybe<LocalizationKeySortInput>; name?: InputMaybe<SortEnumType>;
rawLanguage?: InputMaybe<SortEnumType>; rawLanguage?: InputMaybe<SortEnumType>;
rawStatus?: InputMaybe<SortEnumType>; rawStatus?: InputMaybe<SortEnumType>;
source?: InputMaybe<SourceSortInput>; source?: InputMaybe<SourceDtoSortInput>;
statusOverride?: InputMaybe<SortEnumType>; statusOverride?: InputMaybe<SortEnumType>;
url?: InputMaybe<SortEnumType>; url?: InputMaybe<SortEnumType>;
}; };
@@ -359,27 +317,25 @@ export type NovelStatusOperationFilterInput = {
nin?: InputMaybe<Array<NovelStatus>>; nin?: InputMaybe<Array<NovelStatus>>;
}; };
export type NovelTag = { export type NovelTagDto = {
createdTime: Scalars['Instant']['output']; createdTime: Scalars['Instant']['output'];
displayName: LocalizationKey; displayName: Scalars['String']['output'];
id: Scalars['UnsignedInt']['output']; id: Scalars['UnsignedInt']['output'];
key: Scalars['String']['output']; key: Scalars['String']['output'];
lastUpdatedTime: Scalars['Instant']['output']; lastUpdatedTime: Scalars['Instant']['output'];
novels: Array<Novel>; source: Maybe<SourceDto>;
source: Maybe<Source>;
tagType: TagType; tagType: TagType;
}; };
export type NovelTagFilterInput = { export type NovelTagDtoFilterInput = {
and?: InputMaybe<Array<NovelTagFilterInput>>; and?: InputMaybe<Array<NovelTagDtoFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>; createdTime?: InputMaybe<InstantFilterInput>;
displayName?: InputMaybe<LocalizationKeyFilterInput>; displayName?: InputMaybe<StringOperationFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>; id?: InputMaybe<UnsignedIntOperationFilterInputType>;
key?: InputMaybe<StringOperationFilterInput>; key?: InputMaybe<StringOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>; lastUpdatedTime?: InputMaybe<InstantFilterInput>;
novels?: InputMaybe<ListFilterInputTypeOfNovelFilterInput>; or?: InputMaybe<Array<NovelTagDtoFilterInput>>;
or?: InputMaybe<Array<NovelTagFilterInput>>; source?: InputMaybe<SourceDtoFilterInput>;
source?: InputMaybe<SourceFilterInput>;
tagType?: InputMaybe<TagTypeOperationFilterInput>; tagType?: InputMaybe<TagTypeOperationFilterInput>;
}; };
@@ -392,7 +348,7 @@ export type NovelsConnection = {
/** A list of edges. */ /** A list of edges. */
edges: Maybe<Array<NovelsEdge>>; edges: Maybe<Array<NovelsEdge>>;
/** A flattened list of the nodes. */ /** A flattened list of the nodes. */
nodes: Maybe<Array<Novel>>; nodes: Maybe<Array<NovelDto>>;
/** Information to aid in pagination. */ /** Information to aid in pagination. */
pageInfo: PageInfo; pageInfo: PageInfo;
}; };
@@ -402,7 +358,7 @@ export type NovelsEdge = {
/** A cursor for use in pagination. */ /** A cursor for use in pagination. */
cursor: Scalars['String']['output']; cursor: Scalars['String']['output'];
/** The item at the end of the edge. */ /** The item at the end of the edge. */
node: Novel; node: NovelDto;
}; };
export type NullableOfNovelStatusOperationFilterInput = { export type NullableOfNovelStatusOperationFilterInput = {
@@ -424,38 +380,46 @@ export type PageInfo = {
startCursor: Maybe<Scalars['String']['output']>; startCursor: Maybe<Scalars['String']['output']>;
}; };
export type Person = { export type PersonDto = {
createdTime: Scalars['Instant']['output']; createdTime: Scalars['Instant']['output'];
externalUrl: Maybe<Scalars['String']['output']>; externalUrl: Maybe<Scalars['String']['output']>;
id: Scalars['UnsignedInt']['output']; id: Scalars['UnsignedInt']['output'];
lastUpdatedTime: Scalars['Instant']['output']; lastUpdatedTime: Scalars['Instant']['output'];
name: LocalizationKey; name: Scalars['String']['output'];
}; };
export type PersonFilterInput = { export type PersonDtoFilterInput = {
and?: InputMaybe<Array<PersonFilterInput>>; and?: InputMaybe<Array<PersonDtoFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>; createdTime?: InputMaybe<InstantFilterInput>;
externalUrl?: InputMaybe<StringOperationFilterInput>; externalUrl?: InputMaybe<StringOperationFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>; id?: InputMaybe<UnsignedIntOperationFilterInputType>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>; lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<LocalizationKeyFilterInput>; name?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<PersonFilterInput>>; or?: InputMaybe<Array<PersonDtoFilterInput>>;
}; };
export type PersonSortInput = { export type PersonDtoSortInput = {
createdTime?: InputMaybe<SortEnumType>; createdTime?: InputMaybe<SortEnumType>;
externalUrl?: InputMaybe<SortEnumType>; externalUrl?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>; id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>; lastUpdatedTime?: InputMaybe<SortEnumType>;
name?: InputMaybe<LocalizationKeySortInput>; name?: InputMaybe<SortEnumType>;
}; };
export type Query = { export type Query = {
chapter: Maybe<ChapterReaderDto>;
jobs: Array<SchedulerJob>; jobs: Array<SchedulerJob>;
novels: Maybe<NovelsConnection>; novels: Maybe<NovelsConnection>;
translationEngines: Array<TranslationEngineDescriptor>; translationEngines: Array<TranslationEngineDescriptor>;
translationRequests: Maybe<TranslationRequestsConnection>; translationRequests: Maybe<TranslationRequestsConnection>;
users: Array<User>; users: Array<UserDto>;
};
export type QueryChapterArgs = {
chapterOrder: Scalars['UnsignedInt']['input'];
novelId: Scalars['UnsignedInt']['input'];
preferredLanguage?: Language;
}; };
@@ -464,8 +428,9 @@ export type QueryNovelsArgs = {
before?: InputMaybe<Scalars['String']['input']>; before?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>; first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>; last?: InputMaybe<Scalars['Int']['input']>;
order?: InputMaybe<Array<NovelSortInput>>; order?: InputMaybe<Array<NovelDtoSortInput>>;
where?: InputMaybe<NovelFilterInput>; preferredLanguage?: Language;
where?: InputMaybe<NovelDtoFilterInput>;
}; };
@@ -480,8 +445,8 @@ export type QueryTranslationRequestsArgs = {
before?: InputMaybe<Scalars['String']['input']>; before?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>; first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>; last?: InputMaybe<Scalars['Int']['input']>;
order?: InputMaybe<Array<TranslationRequestSortInput>>; order?: InputMaybe<Array<TranslationRequestDtoSortInput>>;
where?: InputMaybe<TranslationRequestFilterInput>; where?: InputMaybe<TranslationRequestDtoFilterInput>;
}; };
export type RegisterUserInput = { export type RegisterUserInput = {
@@ -492,7 +457,7 @@ export type RegisterUserInput = {
}; };
export type RegisterUserPayload = { export type RegisterUserPayload = {
user: Maybe<User>; userDto: Maybe<UserDto>;
}; };
export type RunJobError = JobPersistenceError; export type RunJobError = JobPersistenceError;
@@ -535,7 +500,7 @@ export const SortEnumType = {
} as const; } as const;
export type SortEnumType = typeof SortEnumType[keyof typeof SortEnumType]; export type SortEnumType = typeof SortEnumType[keyof typeof SortEnumType];
export type Source = { export type SourceDto = {
createdTime: Scalars['Instant']['output']; createdTime: Scalars['Instant']['output'];
id: Scalars['UnsignedInt']['output']; id: Scalars['UnsignedInt']['output'];
key: Scalars['String']['output']; key: Scalars['String']['output'];
@@ -544,18 +509,18 @@ export type Source = {
url: Scalars['String']['output']; url: Scalars['String']['output'];
}; };
export type SourceFilterInput = { export type SourceDtoFilterInput = {
and?: InputMaybe<Array<SourceFilterInput>>; and?: InputMaybe<Array<SourceDtoFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>; createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>; id?: InputMaybe<UnsignedIntOperationFilterInputType>;
key?: InputMaybe<StringOperationFilterInput>; key?: InputMaybe<StringOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>; lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<StringOperationFilterInput>; name?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<SourceFilterInput>>; or?: InputMaybe<Array<SourceDtoFilterInput>>;
url?: InputMaybe<StringOperationFilterInput>; url?: InputMaybe<StringOperationFilterInput>;
}; };
export type SourceSortInput = { export type SourceDtoSortInput = {
createdTime?: InputMaybe<SortEnumType>; createdTime?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>; id?: InputMaybe<SortEnumType>;
key?: InputMaybe<SortEnumType>; key?: InputMaybe<SortEnumType>;
@@ -605,13 +570,6 @@ export type TranslateTextPayload = {
translationResult: Maybe<TranslationResult>; translationResult: Maybe<TranslationResult>;
}; };
export type TranslationEngine = {
createdTime: Scalars['Instant']['output'];
id: Scalars['UnsignedInt']['output'];
key: Scalars['String']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
};
export type TranslationEngineDescriptor = { export type TranslationEngineDescriptor = {
displayName: Scalars['String']['output']; displayName: Scalars['String']['output'];
key: Scalars['String']['output']; key: Scalars['String']['output'];
@@ -629,16 +587,7 @@ export type TranslationEngineDescriptorSortInput = {
key?: InputMaybe<SortEnumType>; key?: InputMaybe<SortEnumType>;
}; };
export type TranslationEngineFilterInput = { export type TranslationRequestDto = {
and?: InputMaybe<Array<TranslationEngineFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
key?: InputMaybe<StringOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<TranslationEngineFilterInput>>;
};
export type TranslationRequest = {
billedCharacterCount: Scalars['UnsignedInt']['output']; billedCharacterCount: Scalars['UnsignedInt']['output'];
createdTime: Scalars['Instant']['output']; createdTime: Scalars['Instant']['output'];
from: Language; from: Language;
@@ -651,14 +600,14 @@ export type TranslationRequest = {
translationEngineKey: Scalars['String']['output']; translationEngineKey: Scalars['String']['output'];
}; };
export type TranslationRequestFilterInput = { export type TranslationRequestDtoFilterInput = {
and?: InputMaybe<Array<TranslationRequestFilterInput>>; and?: InputMaybe<Array<TranslationRequestDtoFilterInput>>;
billedCharacterCount?: InputMaybe<UnsignedIntOperationFilterInputType>; billedCharacterCount?: InputMaybe<UnsignedIntOperationFilterInputType>;
createdTime?: InputMaybe<InstantFilterInput>; createdTime?: InputMaybe<InstantFilterInput>;
from?: InputMaybe<LanguageOperationFilterInput>; from?: InputMaybe<LanguageOperationFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>; id?: InputMaybe<UuidOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>; lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<TranslationRequestFilterInput>>; or?: InputMaybe<Array<TranslationRequestDtoFilterInput>>;
originalText?: InputMaybe<StringOperationFilterInput>; originalText?: InputMaybe<StringOperationFilterInput>;
status?: InputMaybe<TranslationRequestStatusOperationFilterInput>; status?: InputMaybe<TranslationRequestStatusOperationFilterInput>;
to?: InputMaybe<LanguageOperationFilterInput>; to?: InputMaybe<LanguageOperationFilterInput>;
@@ -666,7 +615,7 @@ export type TranslationRequestFilterInput = {
translationEngineKey?: InputMaybe<StringOperationFilterInput>; translationEngineKey?: InputMaybe<StringOperationFilterInput>;
}; };
export type TranslationRequestSortInput = { export type TranslationRequestDtoSortInput = {
billedCharacterCount?: InputMaybe<SortEnumType>; billedCharacterCount?: InputMaybe<SortEnumType>;
createdTime?: InputMaybe<SortEnumType>; createdTime?: InputMaybe<SortEnumType>;
from?: InputMaybe<SortEnumType>; from?: InputMaybe<SortEnumType>;
@@ -698,7 +647,7 @@ export type TranslationRequestsConnection = {
/** A list of edges. */ /** A list of edges. */
edges: Maybe<Array<TranslationRequestsEdge>>; edges: Maybe<Array<TranslationRequestsEdge>>;
/** A flattened list of the nodes. */ /** A flattened list of the nodes. */
nodes: Maybe<Array<TranslationRequest>>; nodes: Maybe<Array<TranslationRequestDto>>;
/** Information to aid in pagination. */ /** Information to aid in pagination. */
pageInfo: PageInfo; pageInfo: PageInfo;
}; };
@@ -708,7 +657,7 @@ export type TranslationRequestsEdge = {
/** A cursor for use in pagination. */ /** A cursor for use in pagination. */
cursor: Scalars['String']['output']; cursor: Scalars['String']['output'];
/** The item at the end of the edge. */ /** The item at the end of the edge. */
node: TranslationRequest; node: TranslationRequestDto;
}; };
export type TranslationResult = { export type TranslationResult = {
@@ -736,14 +685,13 @@ export type UnsignedIntOperationFilterInputType = {
nlte?: InputMaybe<Scalars['UnsignedInt']['input']>; nlte?: InputMaybe<Scalars['UnsignedInt']['input']>;
}; };
export type User = { export type UserDto = {
createdTime: Scalars['Instant']['output']; createdTime: Scalars['Instant']['output'];
disabled: Scalars['Boolean']['output']; disabled: Scalars['Boolean']['output'];
email: Scalars['String']['output']; email: Scalars['String']['output'];
id: Scalars['UUID']['output']; id: Scalars['UUID']['output'];
inviter: Maybe<User>; inviter: Maybe<UserDto>;
lastUpdatedTime: Scalars['Instant']['output']; lastUpdatedTime: Scalars['Instant']['output'];
oAuthProviderId: Scalars['String']['output'];
username: Scalars['String']['output']; username: Scalars['String']['output'];
}; };
@@ -762,13 +710,39 @@ export type UuidOperationFilterInput = {
nlte?: InputMaybe<Scalars['UUID']['input']>; nlte?: InputMaybe<Scalars['UUID']['input']>;
}; };
export type NovelsQueryVariables = Exact<{ export type ImportNovelMutationVariables = Exact<{
first?: InputMaybe<Scalars['Int']['input']>; input: ImportNovelInput;
after?: InputMaybe<Scalars['String']['input']>;
}>; }>;
export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id: any, url: string, name: { texts: Array<{ language: Language, text: string }> }, description: { texts: Array<{ language: Language, text: string }> }, coverImage: { originalPath: string, newPath: string | null } | null } }> | null, pageInfo: { hasNextPage: boolean, endCursor: string | null } } | null }; export type ImportNovelMutation = { importNovel: { novelUpdateRequestedEvent: { novelUrl: string } | null } };
export type GetChapterQueryVariables = Exact<{
novelId: Scalars['UnsignedInt']['input'];
chapterOrder: Scalars['UnsignedInt']['input'];
}>;
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"}}}],"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"}}}],"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"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"texts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"description"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"texts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"originalPath"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}}]}}]}},{"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 type GetChapterQuery = { chapter: { id: any, order: any, name: string, body: string, url: string | null, revision: any, createdTime: any, lastUpdatedTime: any, novelId: any, novelName: string, totalChapters: number, prevChapterOrder: any | null, nextChapterOrder: any | null, images: Array<{ id: any, newPath: string | null }> } | null };
export type NovelQueryVariables = Exact<{
id: Scalars['UnsignedInt']['input'];
}>;
export type NovelQuery = { novels: { nodes: Array<{ id: any, name: string, description: string, url: string, rawLanguage: Language, rawStatus: NovelStatus, statusOverride: NovelStatus | null, externalId: string, createdTime: any, lastUpdatedTime: any, author: { id: any, name: string, externalUrl: string | null }, source: { id: any, name: string, key: string, url: string }, coverImage: { newPath: string | null } | null, tags: Array<{ id: any, key: string, displayName: string, tagType: TagType }>, chapters: Array<{ id: any, order: any, name: string, lastUpdatedTime: any }> }> | null } | null };
export type NovelsQueryVariables = Exact<{
first?: InputMaybe<Scalars['Int']['input']>;
after?: InputMaybe<Scalars['String']['input']>;
where?: InputMaybe<NovelDtoFilterInput>;
}>;
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, chapters: Array<{ order: any, name: string }>, tags: Array<{ key: string, displayName: string }> } }> | null, pageInfo: { hasNextPage: boolean, endCursor: string | null } } | null };
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 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":"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":"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":"totalChapters"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterOrder"}},{"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":"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"}}]}}]}}]}}]}}]} 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"}}}],"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"}}}],"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":"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":"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,21 @@
import { Client, cacheExchange, fetchExchange } from '@urql/core';
import { get } from 'svelte/store';
import { user } from '../auth/authStore';
export function createClient() {
return new Client({
url: import.meta.env.PUBLIC_GRAPHQL_URI,
exchanges: [cacheExchange, fetchExchange],
fetchOptions: () => {
const currentUser = get(user);
return {
headers: currentUser?.access_token
? { Authorization: `Bearer ${currentUser.access_token}` }
: {},
};
},
});
}
// Singleton for use in components
export const client = createClient();

View File

@@ -0,0 +1,7 @@
mutation ImportNovel($input: ImportNovelInput!) {
importNovel(input: $input) {
novelUpdateRequestedEvent {
novelUrl
}
}
}

View File

@@ -0,0 +1,23 @@
query GetChapter($novelId: UnsignedInt!, $chapterOrder: UnsignedInt!) {
chapter(novelId: $novelId, chapterOrder: $chapterOrder) {
id
order
name
body
url
revision
createdTime
lastUpdatedTime
images {
id
newPath
}
novelId
novelName
totalChapters
prevChapterOrder
nextChapterOrder
}
}

View File

@@ -0,0 +1,47 @@
query Novel($id: UnsignedInt!) {
novels(where: { id: { eq: $id } }, first: 1) {
nodes {
id
name
description
url
rawLanguage
rawStatus
statusOverride
externalId
createdTime
lastUpdatedTime
author {
id
name
externalUrl
}
source {
id
name
key
url
}
coverImage {
newPath
}
tags {
id
key
displayName
tagType
}
chapters {
id
order
name
lastUpdatedTime
}
}
}
}

View File

@@ -0,0 +1,30 @@
query Novels($first: Int, $after: String, $where: NovelDtoFilterInput) {
novels(first: $first, after: $after, where: $where) {
edges {
cursor
node {
id
url
name
description
coverImage {
newPath
}
rawStatus
lastUpdatedTime
chapters {
order
name
}
tags {
key
displayName
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}

View File

@@ -0,0 +1,49 @@
import { writable } from 'svelte/store';
import type { OperationResult, TypedDocumentNode } from '@urql/core';
import { client } from './client';
export function queryStore<Data, Variables extends object>(
query: TypedDocumentNode<Data, Variables>,
variables: Variables
) {
const result = writable<OperationResult<Data> | null>(null);
const fetching = writable(true);
async function execute(vars: Variables = variables) {
fetching.set(true);
const res = await client.query(query, vars).toPromise();
result.set(res);
fetching.set(false);
return res;
}
// Initial fetch
execute();
return {
subscribe: result.subscribe,
fetching: { subscribe: fetching.subscribe },
refetch: execute,
};
}
export function mutationStore<Data, Variables extends object>(
mutation: TypedDocumentNode<Data, Variables>
) {
const result = writable<OperationResult<Data> | null>(null);
const fetching = writable(false);
async function execute(variables: Variables) {
fetching.set(true);
const res = await client.mutation(mutation, variables).toPromise();
result.set(res);
fetching.set(false);
return res;
}
return {
subscribe: result.subscribe,
fetching: { subscribe: fetching.subscribe },
execute,
};
}

Some files were not shown because too many files have changed in this diff Show More