From e70c39ea75fb7b9e0df31bd144539b5389467563 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 9 Dec 2025 09:11:39 -0500 Subject: [PATCH] [FA-misc] Refresh button, UI mostly gold --- .../NovelUpdateServiceTests.cs | 134 +++++++++++++++++ .../Services/NovelUpdateService.cs | 15 ++ .../Implementations/RabbitMQEventBus.cs | 1 + .../lib/components/ChapterReaderPage.svelte | 7 - .../lib/components/ImportNovelModal.svelte | 141 ++++++++++++++++++ .../src/lib/components/NovelDetailPage.svelte | 93 ++++++++++-- .../src/lib/components/NovelFilters.svelte | 44 ++++++ .../src/lib/components/NovelsPage.svelte | 18 ++- .../src/lib/graphql/__generated__/graphql.ts | 8 + .../lib/graphql/mutations/importNovel.graphql | 7 + .../src/lib/utils/filterParams.ts | 28 +++- fictionarchive-web-astro/src/middleware.ts | 6 + 12 files changed, 476 insertions(+), 26 deletions(-) create mode 100644 fictionarchive-web-astro/src/lib/components/ImportNovelModal.svelte create mode 100644 fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql diff --git a/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs index b82835b..dad1df3 100644 --- a/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs +++ b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs @@ -162,4 +162,138 @@ public class NovelUpdateServiceTests } 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(); + var eventBus = Substitute.For(); + 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 = $"

Content

\"{image.Id}\""; + chapter.Body.Texts.Add(new LocalizationText + { + Language = Language.En, + Text = bodyHtml + }); + await dbContext.SaveChangesAsync(); + + var adapter = Substitute.For(); + var eventBus = Substitute.For(); + 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 = $"

Content

\"{image1.Id}\"\"{image2.Id}\""; + chapter.Body.Texts.Add(new LocalizationText + { + Language = Language.En, + Text = bodyHtml + }); + await dbContext.SaveChangesAsync(); + + var adapter = Substitute.For(); + var eventBus = Substitute.For(); + 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 } diff --git a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs index 54bead9..04ddfa1 100644 --- a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs +++ b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs @@ -350,6 +350,20 @@ public class NovelUpdateService }); } + // 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; } @@ -434,6 +448,7 @@ public class NovelUpdateService if (match != null) { match.Attributes["src"].Value = newUrl; + bodyText.Text = chapterDoc.DocumentNode.OuterHtml; } } } diff --git a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs index 7faa200..758f2d8 100644 --- a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs +++ b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs @@ -69,6 +69,7 @@ public class RabbitMQEventBus : IEventBus, IHostedService await channel.ExchangeDeclareAsync(ExchangeName, ExchangeType.Direct, cancellationToken: cancellationToken); + await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, cancellationToken: cancellationToken); await channel.QueueDeclareAsync(_options.ClientIdentifier, true, false, false, cancellationToken: cancellationToken); var consumer = new AsyncEventingBasicConsumer(channel); diff --git a/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte b/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte index bd22f39..69572c4 100644 --- a/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte +++ b/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte @@ -13,7 +13,6 @@ import ChapterNavigation from './ChapterNavigation.svelte'; import ChapterProgressBar from './ChapterProgressBar.svelte'; import { sanitizeChapterHtml } from '$lib/utils/sanitizeChapter'; - import ArrowLeft from '@lucide/svelte/icons/arrow-left'; interface Props { novelId?: string; @@ -102,12 +101,6 @@
- - - {#if fetching} diff --git a/fictionarchive-web-astro/src/lib/components/ImportNovelModal.svelte b/fictionarchive-web-astro/src/lib/components/ImportNovelModal.svelte new file mode 100644 index 0000000..f1c1ca5 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/ImportNovelModal.svelte @@ -0,0 +1,141 @@ + + + + +{#if open} + +{/if} diff --git a/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte b/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte index 6430b30..314bc7a 100644 --- a/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte +++ b/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte @@ -30,7 +30,8 @@
@@ -87,6 +106,16 @@ />
+ +
+ handleAuthorInput(e.currentTarget.value)} + /> +
+ {/if} + {#if filters.authorName} + + Author: {filters.authorName} + + + {/if} + {#each filters.statuses as status (status)} {statusOptions.find((o) => o.value === status)?.label ?? status} diff --git a/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte b/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte index c04ba87..21e7d80 100644 --- a/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte +++ b/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte @@ -5,8 +5,10 @@ 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, @@ -26,6 +28,7 @@ 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)); @@ -112,7 +115,14 @@
- Novels +
+ Novels + {#if $isAuthenticated} + + {/if} +

{#if hasActiveFilters(filters)} Showing filtered results @@ -177,3 +187,9 @@

{/if}
+ + (showImportModal = false)} + onSuccess={() => fetchNovels()} +/> diff --git a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts index 8eba6de..6a9a42c 100644 --- a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts +++ b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts @@ -710,6 +710,13 @@ export type UuidOperationFilterInput = { nlte?: InputMaybe; }; +export type ImportNovelMutationVariables = Exact<{ + input: ImportNovelInput; +}>; + + +export type ImportNovelMutation = { importNovel: { novelUpdateRequestedEvent: { novelUrl: string } | null } }; + export type GetChapterQueryVariables = Exact<{ novelId: Scalars['UnsignedInt']['input']; chapterOrder: Scalars['UnsignedInt']['input']; @@ -735,6 +742,7 @@ export type NovelsQueryVariables = Exact<{ export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id: any, url: string, name: string, description: string, rawStatus: NovelStatus, lastUpdatedTime: any, coverImage: { newPath: string | null } | null, 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; 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; 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; 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; \ No newline at end of file diff --git a/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql b/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql new file mode 100644 index 0000000..887afa3 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql @@ -0,0 +1,7 @@ +mutation ImportNovel($input: ImportNovelInput!) { + importNovel(input: $input) { + novelUpdateRequestedEvent { + novelUrl + } + } +} diff --git a/fictionarchive-web-astro/src/lib/utils/filterParams.ts b/fictionarchive-web-astro/src/lib/utils/filterParams.ts index e3319bc..ac4512e 100644 --- a/fictionarchive-web-astro/src/lib/utils/filterParams.ts +++ b/fictionarchive-web-astro/src/lib/utils/filterParams.ts @@ -4,12 +4,14 @@ export interface NovelFilters { search: string; statuses: NovelStatus[]; tags: string[]; + authorName: string; } export const EMPTY_FILTERS: NovelFilters = { search: '', statuses: [], - tags: [] + tags: [], + authorName: '' }; const VALID_STATUSES: NovelStatus[] = ['ABANDONED', 'COMPLETED', 'HIATUS', 'IN_PROGRESS', 'UNKNOWN']; @@ -30,7 +32,9 @@ export function parseFiltersFromURL(searchParams?: URLSearchParams): NovelFilter const tagsParam = params.get('tags') ?? ''; const tags = tagsParam.split(',').filter((t) => t.length > 0); - return { search, statuses, tags }; + const authorName = params.get('author') ?? ''; + + return { search, statuses, tags, authorName }; } /** @@ -51,6 +55,10 @@ export function filtersToURLParams(filters: NovelFilters): string { params.set('tags', filters.tags.join(',')); } + if (filters.authorName.trim()) { + params.set('author', filters.authorName.trim()); + } + return params.toString(); } @@ -95,6 +103,15 @@ export function filtersToGraphQLWhere(filters: NovelFilters): NovelDtoFilterInpu }); } + // Author filter (exact match on author name) + if (filters.authorName.trim()) { + conditions.push({ + author: { + name: { eq: filters.authorName.trim() } + } + }); + } + // Return null if no filters, single condition if one filter, AND for multiple if (conditions.length === 0) { return null; @@ -111,5 +128,10 @@ export function filtersToGraphQLWhere(filters: NovelFilters): NovelDtoFilterInpu * Check if any filters are active */ export function hasActiveFilters(filters: NovelFilters): boolean { - return filters.search.trim().length > 0 || filters.statuses.length > 0 || filters.tags.length > 0; + return ( + filters.search.trim().length > 0 || + filters.statuses.length > 0 || + filters.tags.length > 0 || + filters.authorName.trim().length > 0 + ); } diff --git a/fictionarchive-web-astro/src/middleware.ts b/fictionarchive-web-astro/src/middleware.ts index 653d190..0894821 100644 --- a/fictionarchive-web-astro/src/middleware.ts +++ b/fictionarchive-web-astro/src/middleware.ts @@ -1,6 +1,7 @@ import { defineMiddleware } from 'astro:middleware'; const STATIC_PATHS = ['/_astro/', '/favicon.svg', '/favicon.ico']; +const AUTH_BYPASS_PATHS = ['/gated-404']; export const onRequest = defineMiddleware(async (context, next) => { const { request, url } = context; @@ -10,6 +11,11 @@ export const onRequest = defineMiddleware(async (context, next) => { return next(); } + // Bypass auth for gated pages to prevent redirect loops + if (AUTH_BYPASS_PATHS.includes(url.pathname)) { + return next(); + } + // Simple presence check for fa_session cookie const cookieHeader = request.headers.get('cookie') || ''; const hasSession = /fa_session=[^;]+/.test(cookieHeader);