Compare commits

..

6 Commits

Author SHA1 Message Date
gamer147
4fb34bdef7 [FA-misc] Should fix paragraph blocking
All checks were successful
CI / build-backend (pull_request) Successful in 1m21s
CI / build-frontend (pull_request) Successful in 39s
2025-12-10 13:40:33 -05:00
gamer147
f830773af5 [FA-misc] Gallery
All checks were successful
CI / build-backend (pull_request) Successful in 1m13s
CI / build-frontend (pull_request) Successful in 38s
2025-12-10 12:28:50 -05:00
7185b95c65 Merge pull request '[FA-misc] Couple of misc updates to logic for going live' (#44) from feature/FA-misc_MiscUpdates into master
All checks were successful
CI / build-backend (push) Successful in 57s
CI / build-frontend (push) Successful in 37s
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (push) Successful in 43s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (push) Successful in 42s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (push) Successful in 43s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (push) Successful in 40s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (push) Successful in 1m57s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (push) Successful in 1m57s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (push) Successful in 1m46s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (push) Successful in 1m42s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (push) Successful in 1m44s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (push) Successful in 1m35s
Release / build-frontend (push) Successful in 1m36s
Build Gateway / build-gateway (push) Successful in 3m20s
Reviewed-on: #44
2025-12-10 15:19:25 +00:00
gamer147
8b44cf2f0c [FA-misc] Couple of misc updates to logic for going live
All checks were successful
CI / build-backend (pull_request) Successful in 1m23s
CI / build-frontend (pull_request) Successful in 45s
2025-12-10 10:17:13 -05:00
ac48889f4c Merge pull request '[FA-misc] Fix gateway build' (#43) from feature/FA-misc_FixGatewayBuild into master
All checks were successful
CI / build-backend (push) Successful in 55s
CI / build-frontend (push) Successful in 38s
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (push) Successful in 44s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (push) Successful in 44s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (push) Successful in 46s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (push) Successful in 41s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (push) Successful in 1m51s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (push) Successful in 1m46s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (push) Successful in 1m38s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (push) Successful in 1m40s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (push) Successful in 1m39s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (push) Successful in 1m28s
Release / build-frontend (push) Successful in 1m34s
Build Gateway / build-gateway (push) Successful in 3m9s
Reviewed-on: #43
2025-12-10 04:10:52 +00:00
gamer147
5c52d29da9 [FA-misc] Fix gateway build
All checks were successful
CI / build-backend (pull_request) Successful in 1m12s
CI / build-frontend (pull_request) Successful in 37s
2025-12-09 23:10:35 -05:00
8 changed files with 214 additions and 27 deletions

View File

@@ -24,6 +24,8 @@ RUN dotnet build "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/b
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false /p:SkipFusionBuild=true
# Copy pre-composed gateway.fgp from CI build
COPY FictionArchive.API/gateway.fgp /app/publish/
FROM base AS final
WORKDIR /app

View File

@@ -25,8 +25,6 @@ public class Program
.ConfigureFromFile("gateway.fgp")
.CoreBuilder.ApplySaneDefaults();
builder.Services.AddOidcAuthentication(builder.Configuration);
#endregion
var allowedOrigin = builder.Configuration["Cors:AllowedOrigin"] ?? "http://localhost:4321";

View File

@@ -137,11 +137,9 @@ public class NovelpiaAdapter : ISourceAdapter
uint page = 0;
List<ChapterMetadata> chapters = new List<ChapterMetadata>();
List<uint> seenChapterIds = new List<uint>();
uint chapterOrder = 0;
uint chapterOrder = 1;
while (true)
{
await Task.Delay(500);
_logger.LogInformation("Next chapter batch");
var response = await _httpClient.PostAsync(EpisodeListEndpoint, new FormUrlEncodedContent(new Dictionary<string, string>
{
{"novel_no", novelId.ToString()},

View File

@@ -155,10 +155,9 @@
<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
class="prose prose-lg dark:prose-invert mx-auto max-w-none
prose-headings:text-foreground
first:prose-p:mt-0 last:prose-p:mb-0"
[&>p]:text-foreground [&>p]:mb-6 [&>p]:leading-relaxed"
>
{@html sanitizedBody}
</article>

View File

@@ -51,6 +51,9 @@
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import ChevronUp from '@lucide/svelte/icons/chevron-up';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
interface Props {
novelId?: string;
@@ -66,10 +69,22 @@
let refreshError: string | null = $state(null);
let refreshSuccess = $state(false);
// Image viewer state
type GalleryImage = {
src: string;
alt: string;
chapterId?: number;
chapterOrder?: number;
chapterName?: string;
isCover: boolean;
};
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const DESCRIPTION_PREVIEW_LENGTH = 300;
// Derived values
const coverSrc = $derived(novel?.coverImage?.newPath ?? novel?.coverImage?.originalPath);
const coverSrc = $derived(novel?.coverImage?.newPath);
const status = $derived(novel?.rawStatus ?? 'UNKNOWN');
const statusColor = $derived(statusColors[status]);
const statusLabel = $derived(statusLabels[status]);
@@ -102,6 +117,65 @@
return lastUpdated.getTime() < sixHoursAgo;
});
// Gallery images - cover + chapter images
const galleryImages = $derived.by(() => {
const images: GalleryImage[] = [];
// Add cover image first
if (coverSrc && novel) {
images.push({ src: coverSrc, alt: `${novel.name} cover`, isCover: true });
}
// Add chapter images
for (const chapter of sortedChapters) {
for (const img of chapter.images ?? []) {
if (img.newPath) {
images.push({
src: img.newPath,
alt: `Image from ${chapter.name}`,
chapterId: chapter.id,
chapterOrder: chapter.order,
chapterName: chapter.name,
isCover: false
});
}
}
}
return images;
});
const currentImage = $derived(galleryImages[viewerIndex]);
// Image viewer functions
function openImageViewer(index: number) {
viewerIndex = index;
viewerOpen = true;
}
function closeViewer() {
viewerOpen = false;
}
function nextImage() {
if (galleryImages.length > 0) {
viewerIndex = (viewerIndex + 1) % galleryImages.length;
}
}
function prevImage() {
if (galleryImages.length > 0) {
viewerIndex = (viewerIndex - 1 + galleryImages.length) % galleryImages.length;
}
}
function handleViewerKeydown(e: KeyboardEvent) {
if (!viewerOpen) return;
if (e.key === 'Escape') closeViewer();
if (e.key === 'ArrowRight') nextImage();
if (e.key === 'ArrowLeft') prevImage();
}
async function fetchNovel() {
if (!novelId) {
error = 'No novel ID provided';
@@ -371,18 +445,17 @@
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"
value="gallery"
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all"
>
Comments
Gallery
</TabsTrigger>
<TabsTrigger
value="recommendations"
value="bookmarks"
disabled
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
Recommendations
Bookmarks
</TabsTrigger>
</TabsList>
</CardHeader>
@@ -420,15 +493,32 @@
{/if}
</TabsContent>
<TabsContent value="comments" class="mt-0">
<p class="text-muted-foreground text-sm py-8 text-center">
Comments coming soon.
</p>
<TabsContent value="gallery" class="mt-0">
{#if galleryImages.length === 0}
<p class="text-muted-foreground text-sm py-4 text-center">
No images available.
</p>
{:else}
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
{#each galleryImages as image, index (image.src)}
<button
type="button"
onclick={() => openImageViewer(index)}
class="relative aspect-square overflow-hidden rounded-md bg-muted/50 hover:ring-2 ring-primary transition-all"
>
<img src={image.src} alt={image.alt} class="h-full w-full object-cover" />
{#if image.isCover}
<Badge class="absolute top-1 left-1 text-xs">Cover</Badge>
{/if}
</button>
{/each}
</div>
{/if}
</TabsContent>
<TabsContent value="recommendations" class="mt-0">
<TabsContent value="bookmarks" class="mt-0">
<p class="text-muted-foreground text-sm py-8 text-center">
Recommendations coming soon.
Bookmarks coming soon.
</p>
</TabsContent>
</CardContent>
@@ -436,3 +526,72 @@
</Card>
{/if}
</div>
<!-- Image Viewer Modal -->
{#if viewerOpen && currentImage}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
onclick={closeViewer}
onkeydown={handleViewerKeydown}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<!-- Close button -->
<Button
variant="ghost"
size="icon"
class="absolute top-4 right-4 text-white hover:bg-white/10"
onclick={closeViewer}
>
<X class="h-6 w-6" />
</Button>
<!-- Navigation arrows -->
{#if galleryImages.length > 1}
<Button
variant="ghost"
size="icon"
class="absolute left-4 text-white hover:bg-white/10 h-12 w-12"
onclick={(e: MouseEvent) => { e.stopPropagation(); prevImage(); }}
>
<ChevronLeft class="h-8 w-8" />
</Button>
<Button
variant="ghost"
size="icon"
class="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:bg-white/10 h-12 w-12"
onclick={(e: MouseEvent) => { e.stopPropagation(); nextImage(); }}
>
<ChevronRight class="h-8 w-8" />
</Button>
{/if}
<!-- Image container -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex flex-col items-center max-w-[90vw] max-h-[90vh]" onclick={(e: MouseEvent) => e.stopPropagation()}>
<img
src={currentImage.src}
alt={currentImage.alt}
class="max-w-full max-h-[80vh] object-contain rounded-lg"
/>
<!-- Chapter link (if not cover) -->
{#if !currentImage.isCover && currentImage.chapterOrder}
<a
href="/novels/{novelId}/chapters/{currentImage.chapterOrder}"
class="text-white/80 hover:text-white text-sm inline-flex items-center gap-1 mt-3"
>
From: Ch. {currentImage.chapterOrder} - {currentImage.chapterName}
<ExternalLink class="h-3 w-3" />
</a>
{/if}
<!-- Image counter -->
<div class="text-white/60 text-sm mt-2">
{viewerIndex + 1} / {galleryImages.length}
</div>
</div>
</div>
{/if}

View File

@@ -114,7 +114,6 @@ export type ImageDto = {
id: Scalars['UUID']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
newPath: Maybe<Scalars['String']['output']>;
originalPath: Scalars['String']['output'];
};
export type ImageDtoFilterInput = {
@@ -124,7 +123,6 @@ export type ImageDtoFilterInput = {
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
newPath?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<ImageDtoFilterInput>>;
originalPath?: InputMaybe<StringOperationFilterInput>;
};
export type ImageDtoSortInput = {
@@ -132,7 +130,6 @@ export type ImageDtoSortInput = {
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
newPath?: InputMaybe<SortEnumType>;
originalPath?: InputMaybe<SortEnumType>;
};
export type ImportNovelInput = {
@@ -730,7 +727,7 @@ export type NovelQueryVariables = Exact<{
}>;
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 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, images: Array<{ id: any, newPath: string | null }> }> }> | null } | null };
export type NovelsQueryVariables = Exact<{
first?: InputMaybe<Scalars['Int']['input']>;
@@ -744,5 +741,5 @@ export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id:
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 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"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode<NovelQuery, NovelQueryVariables>;
export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoFilterInput"}}}],"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

@@ -41,6 +41,10 @@ query Novel($id: UnsignedInt!) {
order
name
lastUpdatedTime
images {
id
newPath
}
}
}
}

View File

@@ -1,12 +1,42 @@
import DOMPurify from 'isomorphic-dompurify';
/**
* Splits plain text into paragraphs based on newlines.
* Double newlines create new paragraphs, single newlines become <br>.
* If the content already contains HTML block elements, returns as-is.
*/
function wrapInParagraphs(text: string): string {
// Check if content already has block-level HTML elements
const hasBlockElements = /<(p|div|h[1-6]|ul|ol|blockquote|pre|table|hr)[>\s]/i.test(text);
if (hasBlockElements) {
return text;
}
// Split on double newlines (paragraph breaks)
const paragraphs = text.split(/\n\s*\n/);
return paragraphs
.map((para) => {
const trimmed = para.trim();
if (!trimmed) return '';
// Convert single newlines to <br> within paragraphs
const withBreaks = trimmed.replace(/\n/g, '<br>');
return `<p>${withBreaks}</p>`;
})
.filter(Boolean)
.join('\n');
}
/**
* Sanitizes chapter HTML content with extended allowed tags.
* More permissive than the description sanitizer to support
* formatted novel content including headings, lists, and images.
* Also wraps plain text in paragraph tags for better browser translate support.
*/
export function sanitizeChapterHtml(html: string): string {
return DOMPurify.sanitize(html, {
// First wrap in paragraphs if needed, then sanitize
const wrapped = wrapInParagraphs(html);
return DOMPurify.sanitize(wrapped, {
ALLOWED_TAGS: [
// Basic formatting
'b',