48 Commits

Author SHA1 Message Date
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
gamer147
75e96cbee5 [FA-17] Update auth
All checks were successful
CI / build-backend (pull_request) Successful in 1m13s
CI / build-frontend (pull_request) Successful in 34s
2025-11-27 23:23:03 -05:00
Claude
9c82d648cd fix: address authentication system issues
- Fix GraphQL authorization attributes to use string[] instead of string for roles
- Remove admin role requirement from ImportNovel endpoint
- Add comprehensive OIDC configuration validation with specific error messages
- Validate Authority, ClientId, and Audience are properly configured
- Ensure HTTPS requirement except for localhost development

Co-authored-by: conco <conco@users.noreply.local>
2025-11-27 16:20:09 +00:00
Claude
78612ea29d feat: implement authentication system for API Gateway and FileService
Some checks failed
CI / build-backend (pull_request) Failing after 1m12s
CI / build-frontend (pull_request) Successful in 28s
- Add JWT Bearer token validation to API Gateway with restricted CORS
- Add cookie-based JWT validation to FileService for browser image requests
- Create shared authentication infrastructure in FictionArchive.Service.Shared
- Update frontend to set fa_session cookie after OIDC login
- Add [Authorize] attributes to GraphQL mutations with role-based restrictions
- Configure OIDC settings for both services in docker-compose

Implements FA-17: Authentication for microservices architecture
2025-11-27 14:05:54 +00:00
4412a1f658 Merge pull request 'feature/FA-11_CICD' (#33) from feature/FA-11_CICD into master
All checks were successful
CI / build-backend (push) Successful in 54s
CI / build-frontend (push) Successful in 26s
Reviewed-on: #33
2025-11-26 23:39:45 +00:00
12e3c5dfdd Merge branch 'master' into feature/FA-11_CICD
All checks were successful
CI / build-backend (pull_request) Successful in 57s
CI / build-frontend (pull_request) Successful in 26s
2025-11-26 23:39:35 +00:00
gamer147
b71d9031e1 [FA-11] Finished for real
All checks were successful
CI / build-backend (pull_request) Successful in 1m0s
CI / build-frontend (pull_request) Successful in 26s
2025-11-26 18:26:30 -05:00
gamer147
09ebdb1b2a [FA-11] Cleanup
All checks were successful
CI / build-backend (pull_request) Successful in 1m13s
CI / build-frontend (pull_request) Successful in 26s
2025-11-26 16:08:40 -05:00
43d5ada7fb Update .gitea/workflows/claude_assistant.yml 2025-11-26 18:58:49 +00:00
gamer147
4635ed1b4e [FA-11] Finalized
All checks were successful
CI / build-backend (pull_request) Successful in 55s
CI / build-frontend (pull_request) Successful in 26s
2025-11-26 13:36:22 -05:00
gamer147
920fd00910 [FA-11] Dumb
All checks were successful
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (pull_request) Successful in 55s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (pull_request) Successful in 39s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (pull_request) Successful in 47s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (pull_request) Successful in 40s
CI / build-backend (pull_request) Successful in 50s
CI / build-frontend (pull_request) Successful in 26s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (pull_request) Successful in 1m50s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (pull_request) Successful in 1m47s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (pull_request) Successful in 1m43s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (pull_request) Successful in 1m35s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (pull_request) Successful in 1m40s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (pull_request) Successful in 1m29s
Release / build-frontend (pull_request) Successful in 50s
Build Gateway / build-gateway (pull_request) Successful in 2m59s
2025-11-26 13:11:22 -05:00
gamer147
0d9f788678 [FA-11] Hopefully last
Some checks failed
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (pull_request) Successful in 47s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (pull_request) Successful in 41s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (pull_request) Successful in 43s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (pull_request) Successful in 40s
CI / build-backend (pull_request) Successful in 50s
CI / build-frontend (pull_request) Successful in 27s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (pull_request) Has been cancelled
Release / build-frontend (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (pull_request) Has been cancelled
Build Gateway / build-gateway (pull_request) Failing after 27s
2025-11-26 13:03:28 -05:00
gamer147
0938c16a76 [FA-11] Dumb & cleanup
Some checks failed
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (pull_request) Failing after 44s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (pull_request) Failing after 42s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (pull_request) Failing after 42s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (pull_request) Failing after 42s
Build Gateway / build-gateway (pull_request) Has been skipped
CI / build-backend (pull_request) Successful in 1m11s
CI / build-frontend (pull_request) Successful in 26s
Release / build-and-push (map[dockerfile:FictionArchive.API/Dockerfile name:api]) (pull_request) Successful in 2m21s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (pull_request) Successful in 1m51s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (pull_request) Has been cancelled
Release / build-frontend (pull_request) Has been cancelled
2025-11-26 12:49:07 -05:00
gamer147
f25cbc1a04 [FA-11] Dumb
Some checks failed
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (pull_request) Failing after 42s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (pull_request) Failing after 45s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (pull_request) Failing after 42s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (pull_request) Failing after 40s
Build Gateway / build-gateway (pull_request) Has been skipped
CI / build-frontend (pull_request) Has been cancelled
CI / build-backend (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.API/Dockerfile name:api]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (pull_request) Has been cancelled
Release / build-frontend (pull_request) Has been cancelled
2025-11-26 12:44:51 -05:00
gamer147
078eaf5237 [FA-11] Dumb
Some checks failed
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (pull_request) Failing after 41s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (pull_request) Failing after 39s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (pull_request) Has been cancelled
Build Gateway / build-gateway (pull_request) Has been cancelled
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (pull_request) Has been cancelled
CI / build-frontend (pull_request) Has been cancelled
CI / build-backend (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (pull_request) Has been cancelled
Release / build-frontend (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.API/Dockerfile name:api]) (pull_request) Has been cancelled
2025-11-26 12:42:35 -05:00
gamer147
b9115d78a9 [FA-11] I'm getting sick of fusion but I dont see better alternatives
Some checks failed
Release / build-and-push (map[dockerfile:FictionArchive.API/Dockerfile name:api]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (pull_request) Has been cancelled
Release / build-frontend (pull_request) Has been cancelled
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (pull_request) Failing after 51s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (pull_request) Has been cancelled
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (pull_request) Has been cancelled
Build Gateway / build-gateway (pull_request) Has been cancelled
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (pull_request) Has been cancelled
CI / build-frontend (pull_request) Has been cancelled
CI / build-backend (pull_request) Has been cancelled
2025-11-26 12:40:22 -05:00
gamer147
7e94f06853 [FA-11] Remove FileService graphQL build
Some checks failed
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (pull_request) Failing after 42s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (pull_request) Failing after 40s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (pull_request) Failing after 42s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (pull_request) Failing after 40s
Build Gateway / build-gateway (pull_request) Has been skipped
CI / build-backend (pull_request) Successful in 55s
CI / build-frontend (pull_request) Successful in 27s
Release / build-and-push (map[dockerfile:FictionArchive.API/Dockerfile name:api]) (pull_request) Successful in 2m22s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (pull_request) Successful in 1m46s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (pull_request) Failing after 21s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (pull_request) Successful in 1m39s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (pull_request) Successful in 1m31s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (pull_request) Successful in 1m34s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (pull_request) Successful in 1m27s
Release / build-frontend (pull_request) Successful in 51s
2025-11-26 11:57:18 -05:00
gamer147
50263109ab [FA-11] More pipeline fixes
Some checks failed
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (pull_request) Has been cancelled
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (pull_request) Has been cancelled
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (pull_request) Has been cancelled
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (pull_request) Has been cancelled
Build Gateway / build-gateway (pull_request) Has been cancelled
Build Gateway / build-subgraphs (map[name:file-service project:FictionArchive.Service.FileService subgraph:File]) (pull_request) Has been cancelled
CI / build-frontend (pull_request) Has been cancelled
CI / build-backend (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (pull_request) Has been cancelled
Release / build-frontend (pull_request) Has been cancelled
Release / build-and-push (map[dockerfile:FictionArchive.API/Dockerfile name:api]) (pull_request) Has been cancelled
2025-11-26 11:54:57 -05:00
gamer147
6ebfe81ae3 [FA-11] Test pipelines
Some checks failed
Build Gateway / build-gateway (pull_request) Has been cancelled
Build Subgraphs / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (pull_request) Has been cancelled
Build Subgraphs / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (pull_request) Has been cancelled
Build Subgraphs / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (pull_request) Has been cancelled
Build Subgraphs / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (pull_request) Has been cancelled
Build Subgraphs / trigger-gateway (pull_request) Has been cancelled
Build Subgraphs / build-subgraphs (map[name:file-service project:FictionArchive.Service.FileService subgraph:File]) (pull_request) Has been cancelled
CI / build-backend (pull_request) Successful in 58s
CI / build-frontend (pull_request) Successful in 27s
Release / build-and-push (map[dockerfile:FictionArchive.API/Dockerfile name:api]) (pull_request) Failing after 1m37s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (pull_request) Failing after 19s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (pull_request) Failing after 20s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (pull_request) Failing after 20s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (pull_request) Failing after 19s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (pull_request) Failing after 19s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (pull_request) Failing after 19s
Release / build-frontend (pull_request) Failing after 19s
2025-11-26 11:37:05 -05:00
gamer147
80aac63f7d Merge remote-tracking branch 'origin/feature/FA-11_CICD' into feature/FA-11_CICD
All checks were successful
CI / build-backend (pull_request) Successful in 1m29s
CI / build-frontend (pull_request) Successful in 1m7s
2025-11-26 11:25:57 -05:00
gamer147
adc99c7000 [FA-11] Updated manual dispatch 2025-11-26 11:25:52 -05:00
87075be61e Update .gitea/workflows/claude_assistant.yml 2025-11-26 16:17:03 +00:00
259dc08aea Update .gitea/workflows/claude_assistant.yml 2025-11-26 16:14:48 +00:00
2203d2ee54 Update .gitea/workflows/claude_assistant.yml 2025-11-26 15:54:49 +00:00
30cc89242d Merge branch 'master' into feature/FA-11_CICD
All checks were successful
CI / build-backend (pull_request) Successful in 1m1s
CI / build-frontend (pull_request) Successful in 25s
2025-11-26 15:37:47 +00:00
84294455f9 Update .gitea/workflows/claude_assistant.yml 2025-11-26 15:27:14 +00:00
be62af98d3 Update .gitea/workflows/claude_assistant.yml 2025-11-26 15:19:25 +00:00
gamer147
15a8185621 [FA-11] Fix react build issues
All checks were successful
CI / build-backend (pull_request) Successful in 1m7s
CI / build-frontend (pull_request) Successful in 26s
2025-11-26 08:48:00 -05:00
gamer147
0180a58084 [FA-11] Hopefully resolves build issues, although I don't know why the build_gateway was necessarily failing in build.yml and trying to access Debug bins
Some checks failed
CI / build-backend (pull_request) Successful in 56s
CI / build-frontend (pull_request) Failing after 23s
2025-11-26 07:26:57 -05:00
gamer147
573f3fc7b0 [FA-11] That causes an error so fingers crossed this time
Some checks failed
CI / build-backend (pull_request) Failing after 52s
CI / build-frontend (pull_request) Failing after 21s
2025-11-26 07:11:40 -05:00
gamer147
cdc2176e35 [FA-11] Try and disable the caching again, forgot a step like an idiot
Some checks failed
CI / build-backend (pull_request) Failing after 1m24s
CI / build-frontend (pull_request) Failing after 20s
2025-11-26 07:08:32 -05:00
gamer147
e9eaf1569b [FA-11] Disable Node caching all together and let backend rebuild if needed
Some checks failed
CI / build-backend (pull_request) Failing after 52s
CI / build-frontend (pull_request) Failing after 4m52s
2025-11-26 00:49:27 -05:00
gamer147
ba99642e97 [FA-11] Fix build errors, try to fix cache miss on node build
Some checks failed
CI / build-backend (pull_request) Failing after 1m11s
CI / build-frontend (pull_request) Has been cancelled
2025-11-26 00:40:07 -05:00
gamer147
c6d794aabc Merge remote-tracking branch 'origin/feature/FA-11_CICD' into feature/FA-11_CICD
Some checks failed
CI / build-backend (pull_request) Failing after 53s
CI / build-frontend (pull_request) Failing after 4m52s
2025-11-26 00:18:40 -05:00
gamer147
62e7e20f94 [FA-11] Fix issue with local package reference 2025-11-26 00:18:33 -05:00
aff1396c6a Update .gitea/workflows/claude_assistant.yml 2025-11-26 05:03:53 +00:00
9e1792e4d0 Merge branch 'master' into feature/FA-11_CICD
Some checks failed
CI / build-backend (pull_request) Failing after 1m49s
CI / build-frontend (pull_request) Has been cancelled
2025-11-26 04:50:58 +00:00
gamer147
747a212fb0 Add assistant directly 2025-11-25 23:50:01 -05:00
gamer147
200bdaabed [FA-11] Try to add Claude assistant
Some checks failed
CI / build-frontend (pull_request) Has been cancelled
CI / build-backend (pull_request) Has been cancelled
2025-11-25 23:45:53 -05:00
gamer147
caa36648e2 Haven't checked yet 2025-11-25 23:29:55 -05:00
177 changed files with 20167 additions and 9926 deletions

View File

@@ -0,0 +1,166 @@
name: Build Gateway
on:
workflow_dispatch:
push:
tags:
- 'v*.*.*'
env:
REGISTRY: ${{ gitea.server_url }}
IMAGE_NAME: ${{ gitea.repository_owner }}/fictionarchive-api
jobs:
build-subgraphs:
runs-on: ubuntu-latest
strategy:
matrix:
service:
- name: novel-service
project: FictionArchive.Service.NovelService
subgraph: Novel
- name: translation-service
project: FictionArchive.Service.TranslationService
subgraph: Translation
- name: scheduler-service
project: FictionArchive.Service.SchedulerService
subgraph: Scheduler
- name: user-service
project: FictionArchive.Service.UserService
subgraph: User
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Install Fusion CLI
run: dotnet tool install -g HotChocolate.Fusion.CommandLine
- name: Add .NET tools to PATH
run: echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
- name: Restore dependencies
run: dotnet restore ${{ matrix.service.project }}/${{ matrix.service.project }}.csproj
- name: Build
run: dotnet build ${{ matrix.service.project }}/${{ matrix.service.project }}.csproj -c Release --no-restore
- name: Export schema
run: |
dotnet run -c Release --no-launch-profile \
--project ${{ matrix.service.project }}/${{ matrix.service.project }}.csproj \
-- schema export --output schema.graphql
- name: Pack subgraph
run: fusion subgraph pack -w ${{ matrix.service.project }}
- name: Upload subgraph package
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ matrix.service.name }}-subgraph
path: ${{ matrix.service.project }}/*.fsp
retention-days: 30
build-gateway:
runs-on: ubuntu-latest
needs: build-subgraphs
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Install Fusion CLI
run: dotnet tool install -g HotChocolate.Fusion.CommandLine
- name: Add .NET tools to PATH
run: echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
- name: Create subgraphs directory
run: mkdir -p subgraphs
- name: Download Novel Service subgraph
uses: christopherhx/gitea-download-artifact@v4
with:
name: novel-service-subgraph
path: subgraphs/novel
- name: Download Translation Service subgraph
uses: christopherhx/gitea-download-artifact@v4
with:
name: translation-service-subgraph
path: subgraphs/translation
- name: Download Scheduler Service subgraph
uses: christopherhx/gitea-download-artifact@v4
with:
name: scheduler-service-subgraph
path: subgraphs/scheduler
- name: Download User Service subgraph
uses: christopherhx/gitea-download-artifact@v4
with:
name: user-service-subgraph
path: subgraphs/user
- name: Configure subgraph URLs for Docker
run: |
for fsp in subgraphs/*/*.fsp; do
if [ -f "$fsp" ]; then
dir=$(dirname "$fsp")
name=$(basename "$dir")
url="http://${name}-service:8080/graphql"
echo "Setting $name URL to $url"
fusion subgraph config set http --url "$url" -c "$fsp"
fi
done
- name: Compose gateway
run: |
cd FictionArchive.API
rm -f gateway.fgp
for fsp in ../subgraphs/*/*.fsp; do
if [ -f "$fsp" ]; then
echo "Composing: $fsp"
fusion compose -p gateway.fgp -s "$fsp"
fi
done
- name: Restore dependencies
run: dotnet restore FictionArchive.API/FictionArchive.API.csproj
- name: Build gateway
run: dotnet build FictionArchive.API/FictionArchive.API.csproj -c Release --no-restore -p:SkipFusionBuild=true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract registry hostname
id: registry
run: echo "HOST=$(echo '${{ gitea.server_url }}' | sed 's|https\?://||')" >> $GITHUB_OUTPUT
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: FictionArchive.API/Dockerfile
push: true
tags: |
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_NAME }}:latest
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -0,0 +1,74 @@
name: CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore FictionArchive.sln
- name: Build solution
run: dotnet build FictionArchive.sln --configuration Release --no-restore /p:SkipFusionBuild=true
- name: Run tests
run: |
dotnet test FictionArchive.sln --configuration Release --no-build --verbosity normal \
--logger "trx;LogFileName=test-results.trx" \
--collect:"XPlat Code Coverage" \
--results-directory ./TestResults
- name: Upload test results
uses: christopherhx/gitea-upload-artifact@v4
if: always()
with:
name: test-results
path: ./TestResults/**/*.trx
retention-days: 30
- name: Upload coverage results
uses: christopherhx/gitea-upload-artifact@v4
if: always()
with:
name: coverage-results
path: ./TestResults/**/coverage.cobertura.xml
retention-days: 30
build-frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: fictionarchive-web-astro
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6.0.0
with:
node-version: '20'
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build

View File

@@ -0,0 +1,105 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
env:
REGISTRY: ${{ gitea.server_url }}
IMAGE_PREFIX: ${{ gitea.repository_owner }}/fictionarchive
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
matrix:
service:
- name: novel-service
dockerfile: FictionArchive.Service.NovelService/Dockerfile
- name: user-service
dockerfile: FictionArchive.Service.UserService/Dockerfile
- name: translation-service
dockerfile: FictionArchive.Service.TranslationService/Dockerfile
- name: file-service
dockerfile: FictionArchive.Service.FileService/Dockerfile
- name: scheduler-service
dockerfile: FictionArchive.Service.SchedulerService/Dockerfile
- name: authentication-service
dockerfile: FictionArchive.Service.AuthenticationService/Dockerfile
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Extract registry hostname
id: registry
run: echo "HOST=$(echo '${{ gitea.server_url }}' | sed 's|https\?://||')" >> $GITHUB_OUTPUT
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.service.dockerfile }}
push: true
tags: |
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service.name }}:${{ steps.version.outputs.VERSION }}
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service.name }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Extract registry hostname
id: registry
run: echo "HOST=$(echo '${{ gitea.server_url }}' | sed 's|https\?://||')" >> $GITHUB_OUTPUT
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push frontend Docker image
uses: docker/build-push-action@v6
with:
context: ./fictionarchive-web-astro
file: fictionarchive-web-astro/Dockerfile
push: true
build-args: |
PUBLIC_GRAPHQL_URI=${{ vars.PUBLIC_GRAPHQL_URI }}
PUBLIC_OIDC_AUTHORITY=${{ vars.PUBLIC_OIDC_AUTHORITY }}
PUBLIC_OIDC_CLIENT_ID=${{ vars.PUBLIC_OIDC_CLIENT_ID }}
PUBLIC_OIDC_REDIRECT_URI=${{ vars.PUBLIC_OIDC_REDIRECT_URI }}
PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI=${{ vars.PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI }}
PUBLIC_OIDC_SCOPE=${{ vars.PUBLIC_OIDC_SCOPE }}
tags: |
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-frontend:${{ steps.version.outputs.VERSION }}
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-frontend:latest
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -0,0 +1,405 @@
# FictionArchive Architecture Overview
## High-Level Architecture
```
┌────────────────────────────────────────────────────────────────┐
│ React 19 Frontend │
│ (Apollo Client, TailwindCSS, OIDC Auth) │
└───────────────────────────┬────────────────────────────────────┘
│ GraphQL
┌────────────────────────────────────────────────────────────────┐
│ Hot Chocolate Fusion Gateway │
│ (FictionArchive.API) │
└──────┬────────┬────────┬────────┬────────┬─────────────────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌──────────┐┌──────────┐┌───────────┐┌──────────┐┌──────────────┐
│ Novel ││ User ││Translation││Scheduler ││ File │
│ Service ││ Service ││ Service ││ Service ││ Service │
└────┬─────┘└────┬─────┘└─────┬─────┘└────┬─────┘└──────┬───────┘
│ │ │ │ │
└───────────┴────────────┴───────────┴─────────────┘
┌────────┴────────┐
│ RabbitMQ │
│ (Event Bus) │
└─────────────────┘
┌────────┴────────┐
│ PostgreSQL │
│ (per service) │
└─────────────────┘
```
## Technology Stack
| Layer | Technology | Version |
|-------|------------|---------|
| Runtime | .NET | 8.0 |
| GraphQL | Hot Chocolate / Fusion | 13+ |
| Database | PostgreSQL | 12+ |
| ORM | Entity Framework Core | 8.0 |
| Message Broker | RabbitMQ | 3.12+ |
| Job Scheduler | Quartz.NET | Latest |
| Object Storage | AWS S3 / Garage | - |
| Date/Time | NodaTime | Latest |
| Frontend | React | 19.2 |
| Frontend Build | Vite | 7.2 |
| GraphQL Client | Apollo Client | 4.0 |
| Auth | OIDC Client TS | 3.4 |
| Styling | TailwindCSS | 3.4 |
| UI Components | Radix UI | Latest |
## Project Structure
```
FictionArchive.sln
├── FictionArchive.Common # Shared enums and extensions
├── FictionArchive.API # GraphQL Fusion Gateway
├── FictionArchive.Service.Shared # Shared infrastructure
├── FictionArchive.Service.NovelService
├── FictionArchive.Service.UserService
├── FictionArchive.Service.TranslationService
├── FictionArchive.Service.FileService
├── FictionArchive.Service.SchedulerService
├── FictionArchive.Service.AuthenticationService
├── FictionArchive.Service.NovelService.Tests
└── fictionarchive-web # React frontend
```
## Services
### FictionArchive.API - GraphQL Fusion Gateway
- **Role**: Single entry point for all GraphQL queries
- **Port**: 5001 (HTTPS)
- **Endpoints**:
- `/graphql` - GraphQL endpoint
- `/healthz` - Health check
- **Responsibilities**:
- Compose GraphQL schemas from all subgraphs
- Route queries to appropriate services
- CORS policy management
### FictionArchive.Service.NovelService
- **Role**: Novel/fiction content management
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_NovelService`
- **GraphQL Operations**:
- `GetNovels` - Paginated, filterable novel listing
- `ImportNovel` - Trigger novel import
- `FetchChapterContents` - Fetch chapter content
- **Models**: Novel, Chapter, Source, NovelTag, Image, LocalizationKey
- **External Integration**: Novelpia adapter
- **Events Published**: `TranslationRequestCreatedEvent`, `FileUploadRequestCreatedEvent`
- **Events Subscribed**: `TranslationRequestCompletedEvent`, `NovelUpdateRequestedEvent`, `ChapterPullRequestedEvent`, `FileUploadRequestStatusUpdateEvent`
### FictionArchive.Service.UserService
- **Role**: User identity and profile management
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_UserService`
- **Models**: User (with OAuth provider linking)
- **Events Subscribed**: `AuthUserAddedEvent`
### FictionArchive.Service.TranslationService
- **Role**: Text translation orchestration
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_TranslationService`
- **External Integration**: DeepL API
- **Models**: TranslationRequest
- **Events Published**: `TranslationRequestCompletedEvent`
- **Events Subscribed**: `TranslationRequestCreatedEvent`
### FictionArchive.Service.FileService
- **Role**: File storage and S3 proxy
- **Port**: 8080 (HTTP)
- **Protocol**: REST only (not GraphQL)
- **Endpoints**: `GET /api/{*path}` - S3 file proxy
- **External Integration**: S3-compatible storage (AWS S3 / Garage)
- **Events Published**: `FileUploadRequestStatusUpdateEvent`
- **Events Subscribed**: `FileUploadRequestCreatedEvent`
### FictionArchive.Service.SchedulerService
- **Role**: Job scheduling and automation
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_SchedulerService`
- **Scheduler**: Quartz.NET with persistent job store
- **GraphQL Operations**: `ScheduleEventJob`, `GetScheduledJobs`
- **Models**: SchedulerJob, EventJobTemplate
### FictionArchive.Service.AuthenticationService
- **Role**: OAuth/OIDC webhook receiver
- **Port**: 8080 (HTTP)
- **Protocol**: REST only
- **Endpoints**: `POST /api/AuthenticationWebhook/UserRegistered`
- **Events Published**: `AuthUserAddedEvent`
- **No Database** - Stateless webhook handler
## Communication Patterns
### GraphQL Federation
- Hot Chocolate Fusion Gateway composes subgraph schemas
- Schema export automated via `build_gateway.py`
- Each service defines its own Query/Mutation types
### Event-Driven Architecture (RabbitMQ)
- Direct exchange: `fiction-archive-event-bus`
- Per-service queues based on `ClientIdentifier`
- Routing key = event class name
- Headers: `X-Created-At`, `X-Event-Id`
- NodaTime JSON serialization
### Event Flow Examples
**Novel Import:**
```
1. Frontend → importNovel mutation
2. NovelService publishes NovelUpdateRequestedEvent
3. NovelUpdateRequestedEventHandler processes
4. Fetches metadata via NovelpiaAdapter
5. Publishes FileUploadRequestCreatedEvent (for cover)
6. FileService uploads to S3
7. FileService publishes FileUploadRequestStatusUpdateEvent
8. NovelService updates image path
```
**Translation:**
```
1. NovelService publishes TranslationRequestCreatedEvent
2. TranslationService translates via DeepL
3. TranslationService publishes TranslationRequestCompletedEvent
4. NovelService updates chapter translation
```
## Data Storage
### Database Pattern
- Database per service (PostgreSQL)
- Connection string format: `Host=localhost;Database=FictionArchive_{ServiceName};...`
- Auto-migration on startup via `dbContext.UpdateDatabase()`
### Audit Trail
- `AuditInterceptor` auto-sets `CreatedTime` and `LastUpdatedTime`
- `IAuditable` interface with NodaTime `Instant` fields
- `BaseEntity<TKey>` abstract base class
### Object Storage
- S3-compatible (AWS S3 or Garage)
- Path-style URLs for Garage compatibility
- Proxied through FileService
## Frontend Architecture
### Structure
```
fictionarchive-web/
├── src/
│ ├── auth/ # OIDC authentication
│ ├── components/ # React components
│ │ └── ui/ # Radix-based primitives
│ ├── pages/ # Route pages
│ ├── layouts/ # Layout components
│ ├── graphql/ # GraphQL queries
│ ├── __generated__/ # Codegen output
│ └── lib/ # Utilities
└── codegen.ts # GraphQL Codegen config
```
### Authentication
- OIDC via `oidc-client-ts`
- Environment variables for configuration
- `useAuth` hook for state access
### State Management
- Apollo Client for GraphQL state
- React Context for auth state
## Infrastructure
### Docker
- Multi-stage builds
- Base: `mcr.microsoft.com/dotnet/aspnet:8.0`
- Non-root user for security
- Ports: 8080 (HTTP) or 8081 (HTTPS)
### Health Checks
- All services expose `/healthz`
### Configuration
- `appsettings.json` - Default settings
- `appsettings.Development.json` - Dev overrides
- `appsettings.Local.json` - Local secrets (not committed)
---
# Improvement Recommendations
## Critical
### 1. Event Bus - No Dead Letter Queue or Retry Logic
**Location**: `FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs:126-133`
**Issue**: Events are always ACK'd even on failure. No DLQ configuration for poison messages. Failed events are lost forever.
**Recommendation**: Implement retry with exponential backoff, dead-letter exchange, and poison message handling.
```csharp
// Example: Add retry and DLQ
catch (Exception e)
{
_logger.LogError(e, "Error handling event");
if (retryCount < maxRetries)
{
await channel.BasicNackAsync(@event.DeliveryTag, false, true); // requeue
}
else
{
// Send to DLQ
await channel.BasicNackAsync(@event.DeliveryTag, false, false);
}
}
```
### 2. CORS Configuration is Insecure
**Location**: `FictionArchive.API/Program.cs:24-33`
**Issue**: `AllowAnyOrigin()` allows requests from any domain, unsuitable for production.
**Recommendation**: Configure specific allowed origins via appsettings:
```csharp
builder.Services.AddCors(options =>
{
options.AddPolicy("Production", policy =>
{
policy.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>())
.AllowAnyMethod()
.AllowAnyHeader();
});
});
```
### 3. Auto-Migration on Startup
**Location**: `FictionArchive.Service.Shared/Services/Database/FictionArchiveDbContext.cs:23-38`
**Issue**: Running migrations at startup can cause race conditions with multiple instances and potential data corruption during rolling deployments.
**Recommendation**: Use a migration job, init container, or CLI tool instead of startup code.
## Important
### 4. No Circuit Breaker Pattern
**Issue**: External service calls (DeepL, Novelpia, S3) lack resilience patterns.
**Recommendation**: Add Polly for circuit breaker, retry, and timeout policies:
```csharp
builder.Services.AddHttpClient<ISourceAdapter, NovelpiaAdapter>()
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());
```
### 5. Missing Request Validation/Rate Limiting
**Issue**: No visible rate limiting on GraphQL mutations. `ImportNovel` could be abused.
**Recommendation**: Add rate limiting middleware and input validation.
### 6. Hardcoded Exchange Name
**Location**: `RabbitMQEventBus.cs:24`
**Issue**: `fiction-archive-event-bus` is hardcoded.
**Recommendation**: Move to configuration for environment flexibility.
### 7. No Distributed Tracing
**Issue**: Event correlation exists (`X-Event-Id` header) but not integrated with tracing.
**Recommendation**: Add OpenTelemetry for end-to-end request tracing across services.
### 8. Singleton AuditInterceptor
**Location**: `FictionArchiveDbContext.cs:20`
**Issue**: `new AuditInterceptor()` created per DbContext instance.
**Recommendation**: Register as singleton in DI and inject.
## Minor / Code Quality
### 9. Limited Test Coverage
**Issue**: Only `NovelService.Tests` exists. No integration tests for event handlers.
**Recommendation**: Add unit and integration tests for each service, especially event handlers.
### 10. Inconsistent Port Configuration
**Issue**: Some services use 8080 (HTTP), others 8081 (HTTPS).
**Recommendation**: Standardize on HTTPS with proper cert management.
### 11. No API Versioning
**Issue**: GraphQL schemas have no versioning strategy.
**Recommendation**: Consider schema versioning or deprecation annotations for breaking changes.
### 12. Frontend - No Error Boundary
**Issue**: React app lacks error boundaries for graceful failure handling.
**Recommendation**: Add React Error Boundaries around routes.
### 13. Missing Health Check Depth
**Issue**: Health checks only verify service is running, not dependencies.
**Recommendation**: Add database, RabbitMQ, and S3 health checks:
```csharp
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString)
.AddRabbitMQ()
.AddS3(options => { });
```
### 14. Synchronous File Operations in Event Handlers
**Issue**: File uploads may block event handling thread for large files.
**Recommendation**: Consider async streaming for large files.
## Architectural Suggestions
### 15. Consider Outbox Pattern
**Issue**: Publishing events and saving to DB aren't transactional, could lead to inconsistent state.
**Recommendation**: Implement transactional outbox pattern for guaranteed delivery:
```
1. Save entity + outbox message in same transaction
2. Background worker publishes from outbox
3. Delete outbox message after successful publish
```
### 16. Gateway Schema Build Process
**Issue**: Python script (`build_gateway.py`) for schema composition requires manual execution.
**Recommendation**: Integrate into CI/CD pipeline or consider runtime schema polling.
### 17. Secret Management
**Issue**: Credentials in appsettings files.
**Recommendation**: Use Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, or similar secret management solution.
---
## Key Files Reference
| File | Purpose |
|------|---------|
| `FictionArchive.API/Program.cs` | Gateway setup |
| `FictionArchive.API/build_gateway.py` | Schema composition script |
| `FictionArchive.Service.Shared/Services/EventBus/` | Event bus implementation |
| `FictionArchive.Service.Shared/Extensions/` | Service registration helpers |
| `FictionArchive.Service.Shared/Services/Database/` | DB infrastructure |
| `fictionarchive-web/src/auth/AuthContext.tsx` | Frontend auth state |

297
Documentation/CICD.md Normal file
View File

@@ -0,0 +1,297 @@
# CI/CD Configuration
This document describes the CI/CD pipeline configuration for FictionArchive using Gitea Actions.
## Workflows Overview
| Workflow | File | Trigger | Purpose |
|----------|------|---------|---------|
| CI | `build.yml` | Push/PR to master | Build and test all projects |
| Build Gateway | `build-gateway.yml` | Tag `v*.*.*` or manual | Build subgraphs, compose gateway, push API image |
| Release | `release.yml` | Tag `v*.*.*` | Build and push all Docker images |
| Claude PR Assistant | `claude_assistant.yml` | Issue/PR comments with @claude | AI-assisted code review and issue handling |
## Pipeline Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Push to master │
└─────────────────────────────┬───────────────────────────────────────┘
┌─────────────────────────┐
│ build.yml │
│ (CI checks) │
└─────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Push tag v*.*.* │
└─────────────────────────────┬───────────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ release.yml │ │ build-gateway.yml │
│ (build & push all │ │ (build subgraphs & │
│ backend + frontend) │ │ push API gateway) │
└─────────────────────────┘ └─────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Issue/PR comment containing @claude │
└─────────────────────────────┬───────────────────────────────────────┘
┌─────────────────────────┐
│ claude_assistant.yml │
│ (AI code assistance) │
└─────────────────────────┘
```
## Required Configuration
### Repository Secrets
Configure these in **Settings → Actions → Secrets**:
| Secret | Description | Required By |
|--------|-------------|-------------|
| `REGISTRY_TOKEN` | Gitea access token with `write:package` scope | `release.yml`, `build-gateway.yml` |
| `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code OAuth token | `claude_assistant.yml` |
| `CLAUDE_GITEA_TOKEN` | Gitea token for Claude assistant | `claude_assistant.yml` |
#### Creating Access Tokens
1. Go to **Settings → Applications → Access Tokens**
2. Create a new token with the following scopes:
- `write:package` - Push container images
- `write:repository` - For Claude assistant to push commits
3. Copy the token and add it as a repository secret
### Repository Variables
Configure these in **Settings → Actions → Variables**:
| Variable | Description | Example | Required By |
|----------|-------------|---------|-------------|
| `VITE_GRAPHQL_URI` | GraphQL API endpoint URL | `https://api.fictionarchive.example.com/graphql/` | `release.yml` |
| `VITE_OIDC_AUTHORITY` | OIDC provider authority URL | `https://auth.example.com/application/o/fiction-archive/` | `release.yml` |
| `VITE_OIDC_CLIENT_ID` | OIDC client identifier | `your-client-id` | `release.yml` |
| `VITE_OIDC_REDIRECT_URI` | Post-login redirect URL | `https://fictionarchive.example.com/` | `release.yml` |
| `VITE_OIDC_POST_LOGOUT_REDIRECT_URI` | Post-logout redirect URL | `https://fictionarchive.example.com/` | `release.yml` |
## Workflow Details
### CI (`build.yml`)
**Trigger:** Push or pull request to `master`
**Jobs:**
1. `build-backend` - Builds .NET solution and runs tests
2. `build-frontend` - Builds React application with linting
**Requirements:**
- .NET 8.0 SDK
- Node.js 20
**Steps (Backend):**
1. Checkout repository
2. Setup .NET 8.0
3. Restore dependencies
4. Build solution (Release, with `SkipFusionBuild=true`)
5. Run tests
**Steps (Frontend):**
1. Checkout repository
2. Setup Node.js 20
3. Install dependencies (`npm ci`)
4. Run linter (`npm run lint`)
5. Build application (`npm run build`)
### Build Gateway (`build-gateway.yml`)
**Trigger:**
- Manual dispatch (`workflow_dispatch`)
- Push tag matching `v*.*.*`
**Jobs:**
#### 1. `build-subgraphs` (Matrix Job)
Builds GraphQL subgraph packages for each service:
| Service | Project | Subgraph Name |
|---------|---------|---------------|
| novel-service | FictionArchive.Service.NovelService | Novel |
| translation-service | FictionArchive.Service.TranslationService | Translation |
| scheduler-service | FictionArchive.Service.SchedulerService | Scheduler |
| user-service | FictionArchive.Service.UserService | User |
**Note:** File Service and Authentication Service are not subgraphs (no GraphQL schema).
**Steps:**
1. Checkout repository
2. Setup .NET 8.0
3. Install HotChocolate Fusion CLI
4. Restore and build service project
5. Export GraphQL schema (`schema export`)
6. Pack subgraph into `.fsp` file
7. Upload artifact (retained 30 days)
#### 2. `build-gateway` (Depends on `build-subgraphs`)
Composes the API gateway from subgraph packages.
**Steps:**
1. Checkout repository
2. Setup .NET 8.0 and Fusion CLI
3. Download all subgraph artifacts
4. Configure Docker-internal URLs (`http://{service}-service:8080/graphql`)
5. Compose gateway schema using Fusion CLI
6. Build gateway project
7. Build and push Docker image
**Image Tags:**
- `<registry>/<owner>/fictionarchive-api:latest`
- `<registry>/<owner>/fictionarchive-api:<commit-sha>`
### Release (`release.yml`)
**Trigger:** Push tag matching `v*.*.*` (e.g., `v1.0.0`)
**Jobs:**
#### 1. `build-and-push` (Matrix Job)
Builds and pushes all backend service images:
| Service | Dockerfile |
|---------|------------|
| novel-service | FictionArchive.Service.NovelService/Dockerfile |
| user-service | FictionArchive.Service.UserService/Dockerfile |
| translation-service | FictionArchive.Service.TranslationService/Dockerfile |
| file-service | FictionArchive.Service.FileService/Dockerfile |
| scheduler-service | FictionArchive.Service.SchedulerService/Dockerfile |
| authentication-service | FictionArchive.Service.AuthenticationService/Dockerfile |
#### 2. `build-frontend`
Builds and pushes the frontend image with environment-specific build arguments.
**Build Args:**
- `VITE_GRAPHQL_URI`
- `VITE_OIDC_AUTHORITY`
- `VITE_OIDC_CLIENT_ID`
- `VITE_OIDC_REDIRECT_URI`
- `VITE_OIDC_POST_LOGOUT_REDIRECT_URI`
**Image Tags:**
- `<registry>/<owner>/fictionarchive-<service>:<version>`
- `<registry>/<owner>/fictionarchive-<service>:latest`
### Claude PR Assistant (`claude_assistant.yml`)
**Trigger:** Comments or issues containing `@claude`:
- Issue comments
- Pull request review comments
- Pull request reviews
- New issues (opened or assigned)
**Permissions Required:**
- `contents: write`
- `pull-requests: write`
- `issues: write`
- `id-token: write`
**Usage:**
Mention `@claude` in any issue or PR comment to invoke the AI assistant for:
- Code review assistance
- Bug analysis
- Implementation suggestions
- Documentation help
## Container Registry
Images are pushed to the Gitea Container Registry at:
```
<gitea-server-url>/<repository-owner>/fictionarchive-<service>:<tag>
```
### Image Naming Convention
| Image | Description |
|-------|-------------|
| `fictionarchive-api` | API Gateway (GraphQL Federation) |
| `fictionarchive-novel-service` | Novel Service |
| `fictionarchive-user-service` | User Service |
| `fictionarchive-translation-service` | Translation Service |
| `fictionarchive-file-service` | File Service |
| `fictionarchive-scheduler-service` | Scheduler Service |
| `fictionarchive-authentication-service` | Authentication Service |
| `fictionarchive-frontend` | Web Frontend |
### Pulling Images
```bash
# Login to registry
docker login <gitea-server-url> -u <username> -p <token>
# Pull an image
docker pull <gitea-server-url>/<owner>/fictionarchive-api:latest
```
## Creating a Release
1. Ensure all changes are committed and pushed to `master`
2. Create and push a version tag:
```bash
git tag v1.0.0
git push origin v1.0.0
```
3. The release workflow will automatically build and push all images
4. Monitor progress in **Actions** tab
## Troubleshooting
### Build Failures
**"REGISTRY_TOKEN secret not found"**
- Ensure the `REGISTRY_TOKEN` secret is configured in repository settings
- Verify the token has `write:package` scope
**"No subgraph artifacts found"**
- The gateway build requires subgraph artifacts from the `build-subgraphs` job
- If subgraph builds failed, check the matrix job logs for errors
**"Schema export failed"**
- Ensure the service project has a valid `subgraph-config.json`
- Check that the service starts correctly for schema export
### Frontend Build Failures
**"VITE_* variables are empty"**
- Ensure all required variables are configured in repository settings
- Variables use `vars.*` context, not `secrets.*`
### Docker Push Failures
**"unauthorized: authentication required"**
- Verify `REGISTRY_TOKEN` has correct permissions
- Check that the token hasn't expired
### Claude Assistant Failures
**"Claude assistant not responding"**
- Verify `CLAUDE_CODE_OAUTH_TOKEN` is configured
- Verify `CLAUDE_GITEA_TOKEN` is configured and has write permissions
- Check that the comment contains `@claude` mention
## Local Testing
To test workflows locally before pushing:
```bash
# Install act (GitHub Actions local runner)
# Note: act has partial Gitea Actions compatibility
# Run CI workflow
act push -W .gitea/workflows/build.yml
# Run with specific event
act push --eventpath .gitea/test-event.json
```

187
Documentation/README.md Normal file
View File

@@ -0,0 +1,187 @@
# FictionArchive
A distributed microservices-based web application for managing fiction and novel content. Features include importing from external sources, multi-language translation, file storage, and user management.
## Architecture
FictionArchive uses a GraphQL Fusion gateway pattern to orchestrate multiple domain services with event-driven communication via RabbitMQ.
More information available in [ARCHITECTURE.md](ARCHITECTURE.md)
## Prerequisites
- .NET SDK 8.0+
- Node.js 20+
- Python 3 (for gateway build script)
- Docker & Docker Compose
- PostgreSQL 16+
- RabbitMQ 3+
**Required CLI Tools**
```bash
# Hot Chocolate Fusion CLI
dotnet tool install -g HotChocolate.Fusion.CommandLine
```
## Getting Started
### Local Development
1. **Clone the repository**
```bash
git clone <repository-url>
cd FictionArchive
```
2. **Start infrastructure** (PostgreSQL, RabbitMQ)
```bash
docker compose up -d postgres rabbitmq
```
3. **Build and run backend**
```bash
dotnet restore
dotnet build FictionArchive.sln
# Start services (in separate terminals or use a process manager)
dotnet run --project FictionArchive.Service.NovelService
dotnet run --project FictionArchive.Service.UserService
dotnet run --project FictionArchive.Service.TranslationService
dotnet run --project FictionArchive.Service.FileService
dotnet run --project FictionArchive.Service.SchedulerService
dotnet run --project FictionArchive.Service.AuthenticationService
# Start the gateway (builds fusion schema automatically)
dotnet run --project FictionArchive.API
```
4. **Build and run frontend**
```bash
cd fictionarchive-web
npm install
npm run codegen # Generate GraphQL types
npm run dev # Start dev server at http://localhost:5173
```
### Docker Deployment
1. **Create environment file**
```bash
cp .env.example .env
# Edit .env with your configuration
```
2. **Start all services**
```bash
docker compose up -d
```
## Configuration
### Environment Variables
Create a `.env` file in the project root:
```bash
# PostgreSQL
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-secure-password
# RabbitMQ
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=your-secure-password
# External Services
NOVELPIA_USERNAME=your-username
NOVELPIA_PASSWORD=your-password
DEEPL_API_KEY=your-api-key
# S3 Storage
S3_ENDPOINT=https://s3.example.com
S3_BUCKET=fictionarchive
S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key
# OIDC Authentication
OIDC_AUTHORITY=https://auth.example.com/application/o/fiction-archive/
OIDC_CLIENT_ID=your-client-id
```
### Frontend Environment
Create `fictionarchive-web/.env.local`:
```bash
VITE_GRAPHQL_URI=http://localhost:5234/graphql/
VITE_OIDC_AUTHORITY=https://auth.example.com/application/o/fiction-archive/
VITE_OIDC_CLIENT_ID=your-client-id
VITE_OIDC_REDIRECT_URI=http://localhost:5173/
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/
```
## Building the GraphQL Gateway
The API gateway uses Hot Chocolate Fusion to compose schemas from all subgraphs. The gateway schema is rebuilt automatically when building the API project.
**Manual rebuild:**
```bash
cd FictionArchive.API
python build_gateway.py
```
**Skip specific services** by adding them to `FictionArchive.API/gateway_skip.txt`:
```
FictionArchive.Service.NovelService.Tests
```
## CI/CD
The project uses Gitea Actions with the following workflows:
| Workflow | Trigger | Description |
|----------|---------|-------------|
| `build.yml` | Push/PR to master | CI checks - builds and tests |
| `build-subgraphs.yml` | Service changes on master | Builds subgraph `.fsp` packages |
| `build-gateway.yml` | Gateway changes or subgraph builds | Composes gateway and builds Docker image |
| `release.yml` | Tag `v*.*.*` | Builds and pushes all Docker images |
### Release Process
```bash
git tag v1.0.0
git push origin v1.0.0
```
## Project Structure
```
FictionArchive/
├── FictionArchive.sln
├── FictionArchive.Common/ # Shared enums and extensions
├── FictionArchive.Service.Shared/ # Shared infrastructure (EventBus, DB)
├── FictionArchive.API/ # GraphQL Fusion Gateway
├── FictionArchive.Service.NovelService/
├── FictionArchive.Service.UserService/
├── FictionArchive.Service.TranslationService/
├── FictionArchive.Service.FileService/
├── FictionArchive.Service.SchedulerService/
├── FictionArchive.Service.AuthenticationService/
├── FictionArchive.Service.NovelService.Tests/
├── fictionarchive-web/ # React frontend
├── docker-compose.yml
└── .gitea/workflows/ # CI/CD workflows
```
## Testing
```bash
# Run all tests
dotnet test FictionArchive.sln
# Run specific test project
dotnet test FictionArchive.Service.NovelService.Tests
```
## Documentation
- [ARCHITECTURE.md](ARCHITECTURE.md) - Detailed architecture documentation
- [AGENTS.md](AGENTS.md) - Development guidelines and coding standards

View File

@@ -7,15 +7,23 @@ EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FictionArchive.API/FictionArchive.API.csproj", "FictionArchive.API/"]
COPY ["FictionArchive.Common/FictionArchive.Common.csproj", "FictionArchive.Common/"]
COPY ["FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj", "FictionArchive.Service.Shared/"]
RUN dotnet restore "FictionArchive.API/FictionArchive.API.csproj"
COPY . .
COPY FictionArchive.API/ FictionArchive.API/
COPY FictionArchive.Common/ FictionArchive.Common/
COPY FictionArchive.Service.Shared/ FictionArchive.Service.Shared/
WORKDIR "/src/FictionArchive.API"
RUN dotnet build "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/build
# Skip fusion build - gateway.fgp should be pre-composed in CI
RUN dotnet build "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/build -p:SkipFusionBuild=true
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN dotnet publish "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false /p:SkipFusionBuild=true
FROM base AS final
WORKDIR /app

View File

@@ -13,6 +13,7 @@
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
<PackageReference Include="HotChocolate.Fusion" Version="15.1.11" />
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
<PackageReference Include="Microsoft.AspNetCore.HeaderPropagation" Version="8.0.22" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -20,11 +21,12 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
</ItemGroup>
<!-- Builds the Fusion graph file before building the application itself -->
<Target Name="RunFusionBuild" BeforeTargets="BeforeBuild">
<Exec Command="python build_gateway.py" WorkingDirectory="$(ProjectDir)" />
<!-- Builds the Fusion graph file before building the application itself (skipped in CI) -->
<Target Name="RunFusionBuild" BeforeTargets="BeforeBuild" Condition="'$(SkipFusionBuild)' != 'true'">
<Exec Command="python build_gateway.py $(FusionBuildArgs)" WorkingDirectory="$(ProjectDir)" />
</Target>
<ItemGroup>

View File

@@ -12,32 +12,44 @@ public class Program
#region Fusion Gateway
builder.Services.AddHttpClient("Fusion");
// Register header propagation service to forward Authorization header to subgraphs
builder.Services.AddHttpClient("Fusion")
.AddHeaderPropagation();
builder.Services.AddHeaderPropagation(opt =>
{
opt.Headers.Add("Authorization");
});
builder.Services
.AddFusionGatewayServer()
.ConfigureFromFile("gateway.fgp")
.CoreBuilder.ApplySaneDefaults();
builder.Services.AddOidcAuthentication(builder.Configuration);
#endregion
var allowedOrigin = builder.Configuration["Cors:AllowedOrigin"] ?? "http://localhost:4321";
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAllOrigins",
builder =>
options.AddPolicy("AllowFictionArchiveOrigins",
policyBuilder =>
{
builder.AllowAnyOrigin()
policyBuilder.WithOrigins(allowedOrigin)
.AllowAnyMethod()
.AllowAnyHeader();
.AllowAnyHeader()
.AllowCredentials();
});
});
var app = builder.Build();
app.UseCors("AllowAllOrigins");
app.UseCors("AllowFictionArchiveOrigins");
app.MapHealthChecks("/healthz");
app.UseHeaderPropagation();
app.MapGraphQL();
app.RunWithGraphQLCommands(args);

View File

@@ -5,5 +5,18 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"Cors": {
"AllowedOrigin": "http://localhost:4321"
},
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true
}
}

View File

@@ -1,12 +1,22 @@
#!/usr/bin/env python3
"""
Local development script for building the Fusion gateway.
This script is used for local development only. In CI/CD, subgraphs are built
separately and the gateway is composed from pre-built .fsp artifacts.
Usage:
python build_gateway.py
Requirements:
- .NET 8.0 SDK
- HotChocolate Fusion CLI (dotnet tool install -g HotChocolate.Fusion.CommandLine)
"""
import subprocess
import sys
import os
from pathlib import Path
# ----------------------------------------
# Helpers
# ----------------------------------------
def run(cmd, cwd=None):
"""Run a command and exit on failure."""
@@ -19,7 +29,7 @@ def run(cmd, cwd=None):
def load_skip_list(skip_file: Path):
if not skip_file.exists():
print(f"WARNING: skip-projects.txt not found at {skip_file}")
print(f"WARNING: gateway_skip.txt not found at {skip_file}")
return set()
lines = skip_file.read_text().splitlines()
@@ -53,7 +63,7 @@ print("----------------------------------------")
service_dirs = [
d for d in services_dir.glob("FictionArchive.Service.*")
if d.is_dir()
if d.is_dir() and (d / "subgraph-config.json").exists()
]
selected_services = []

View File

@@ -9,14 +9,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" />
<PackageReference Include="NodaTime" Version="3.2.2" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.Configuration.Abstractions" />
<Reference Include="Microsoft.Extensions.Hosting.Abstractions">
<HintPath>..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.15\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@@ -2,6 +2,7 @@ using System.Web;
using Amazon.S3;
using Amazon.S3.Model;
using FictionArchive.Service.FileService.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -10,6 +11,7 @@ namespace FictionArchive.Service.FileService.Controllers
{
[Route("api/{*path}")]
[ApiController]
[Authorize]
public class S3ProxyController : ControllerBase
{
private readonly AmazonS3Client _amazonS3Client;

View File

@@ -7,17 +7,17 @@ EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FictionArchive.Service.ImageService/FictionArchive.Service.ImageService.csproj", "FictionArchive.Service.ImageService/"]
RUN dotnet restore "FictionArchive.Service.ImageService/FictionArchive.Service.ImageService.csproj"
COPY ["FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj", "FictionArchive.Service.FileService/"]
RUN dotnet restore "FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj"
COPY . .
WORKDIR "/src/FictionArchive.Service.ImageService"
RUN dotnet build "./FictionArchive.Service.ImageService.csproj" -c $BUILD_CONFIGURATION -o /app/build
WORKDIR "/src/FictionArchive.Service.FileService"
RUN dotnet build "./FictionArchive.Service.FileService.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.Service.ImageService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN dotnet publish "./FictionArchive.Service.FileService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FictionArchive.Service.ImageService.dll"]
ENTRYPOINT ["dotnet", "FictionArchive.Service.FileService.dll"]

View File

@@ -21,6 +21,7 @@
<PackageReference Include="AWSSDK.S3" Version="4.0.13.1" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
</ItemGroup>
<ItemGroup>

View File

@@ -34,6 +34,10 @@ public class Program
#endregion
// Add authentication with cookie support
builder.Services.AddOidcCookieAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
builder.Services.Configure<ProxyConfiguration>(builder.Configuration.GetSection("ProxyConfiguration"));
// Add S3 Client
@@ -60,6 +64,9 @@ public class Program
app.UseSwaggerUI();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapControllers();

View File

@@ -18,5 +18,15 @@
"AccessKey": "REPLACE_ME",
"SecretKey": "REPLACE_ME"
},
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true
},
"AllowedHosts": "*"
}

View File

@@ -6,32 +6,24 @@ using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.Shared.Services.EventBus;
using HotChocolate.Authorization;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.GraphQL;
public class Mutation
{
public async Task<NovelUpdateRequestedEvent> ImportNovel(string novelUrl, IEventBus eventBus)
[Authorize]
public async Task<NovelUpdateRequestedEvent> ImportNovel(string novelUrl, NovelUpdateService service)
{
var importNovelRequestEvent = new NovelUpdateRequestedEvent()
{
NovelUrl = novelUrl
};
await eventBus.Publish(importNovelRequestEvent);
return importNovelRequestEvent;
return await service.QueueNovelImport(novelUrl);
}
[Authorize]
public async Task<ChapterPullRequestedEvent> FetchChapterContents(uint novelId,
uint chapterNumber,
IEventBus eventBus)
NovelUpdateService service)
{
var chapterPullEvent = new ChapterPullRequestedEvent()
{
NovelId = novelId,
ChapterNumber = chapterNumber
};
await eventBus.Publish(chapterPullEvent);
return chapterPullEvent;
return await service.QueueChapterPull(novelId, chapterNumber);
}
}

View File

@@ -1,5 +1,7 @@
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.DTOs;
using FictionArchive.Service.NovelService.Services;
using HotChocolate.Authorization;
using HotChocolate.Data;
using HotChocolate.Types;
@@ -7,12 +9,191 @@ namespace FictionArchive.Service.NovelService.GraphQL;
public class Query
{
[Authorize]
[UsePaging]
[UseProjection]
[UseFiltering]
[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")
.HasColumnType("uuid");
b.Property<long?>("NovelId")
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order")
@@ -234,6 +234,9 @@ namespace FictionArchive.Service.NovelService.Migrations
b.HasIndex("SourceId");
b.HasIndex("ExternalId", "SourceId")
.IsUnique();
b.ToTable("Novels");
});
@@ -424,13 +427,17 @@ namespace FictionArchive.Service.NovelService.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel")
.WithMany("Chapters")
.HasForeignKey("NovelId");
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Body");
b.Navigation("Name");
b.Navigation("Novel");
});
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.Localization;
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.NovelService.Models.Novels;
[Table("Chapter")]
public class Chapter : BaseEntity<uint>
{
public uint Revision { get; set; }
@@ -15,4 +17,10 @@ public class Chapter : BaseEntity<uint>
// Images appearing in this chapter.
public List<Image> Images { get; set; }
#region Navigation Properties
public Novel Novel { get; set; }
#endregion
}

View File

@@ -6,6 +6,7 @@ using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.EventHandlers;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.Shared.Services.GraphQL;
@@ -17,6 +18,8 @@ public class Program
{
public static void Main(string[] args)
{
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
var builder = WebApplication.CreateBuilder(args);
builder.AddLocalAppsettings();
@@ -24,26 +27,33 @@ public class Program
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
if (!isSchemaExport)
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<TranslationRequestCompletedEvent, TranslationRequestCompletedEventHandler>()
.Subscribe<NovelUpdateRequestedEvent, NovelUpdateRequestedEventHandler>()
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>()
.Subscribe<FileUploadRequestStatusUpdateEvent, FileUploadRequestStatusUpdateEventHandler>();
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<TranslationRequestCompletedEvent, TranslationRequestCompletedEventHandler>()
.Subscribe<NovelUpdateRequestedEvent, NovelUpdateRequestedEventHandler>()
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>()
.Subscribe<FileUploadRequestStatusUpdateEvent, FileUploadRequestStatusUpdateEventHandler>();
}
#endregion
#region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>();
builder.Services.AddDefaultGraphQl<Query, Mutation>()
.ModifyCostOptions(opt => opt.MaxFieldCost = 5000)
.AddAuthorization();
#endregion
#region Database
builder.Services.RegisterDbContext<NovelServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
builder.Services.RegisterDbContext<NovelServiceDbContext>(
builder.Configuration.GetConnectionString("DefaultConnection"),
skipInfrastructure: isSchemaExport);
#endregion
@@ -67,11 +77,16 @@ public class Program
builder.Services.AddHealthChecks();
// Authentication & Authorization
builder.Services.AddOidcAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
var app = builder.Build();
// Update database
using (var scope = app.Services.CreateScope())
// Update database (skip in schema export mode)
if (!isSchemaExport)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<NovelServiceDbContext>();
dbContext.UpdateDatabase();
}
@@ -79,7 +94,10 @@ public class Program
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.RunWithGraphQLCommands(args);

View File

@@ -10,10 +10,20 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
: FictionArchiveDbContext(options, logger)
{
public DbSet<Novel> Novels { get; set; }
public DbSet<Chapter> Chapters { get; set; }
public DbSet<Source> Sources { get; set; }
public DbSet<TranslationEngine> TranslationEngines { get; set; }
public DbSet<NovelTag> Tags { get; set; }
public DbSet<LocalizationKey> LocalizationKeys { get; set; }
public DbSet<LocalizationRequest> LocalizationRequests { 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,7 +1,9 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.FileService.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Configuration;
using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.Images;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters;
@@ -30,14 +32,241 @@ public class NovelUpdateService
_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)
{
// Step 1: Get metadata from source adapter
NovelMetadata? metadata = null;
foreach (ISourceAdapter sourceAdapter in _sourceAdapters)
{
if (await sourceAdapter.CanProcessNovel(novelUrl))
{
metadata = await sourceAdapter.GetMetadata(novelUrl);
break; // Stop after finding adapter
}
}
@@ -46,72 +275,82 @@ public class NovelUpdateService
throw new NotSupportedException("The provided novel url is currently unsupported.");
}
var systemTags = metadata.SystemTags.Select(tag => new NovelTag()
{
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
});
// Step 2: Resolve or create Source
var source = await GetOrCreateSource(metadata.SourceDescriptor);
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()
{
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
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>()
}
};
}).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,
Url = metadata.SourceDescriptor.Url,
Key = metadata.SourceDescriptor.Key,
}
});
// CREATE PATH: New novel
novel = await CreateNewNovel(metadata, source);
shouldPublishCoverEvent = novel.CoverImage != null;
}
else
{
// UPDATE PATH: Existing novel
novel = existingNovel;
// 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();
// Signal request for cover image if present
if (addedNovel.Entity.CoverImage != null)
// Publish cover image event if needed
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,
FilePath = $"Novels/{addedNovel.Entity.Id}/Images/cover.jpg"
FilePath = $"Novels/{novel.Id}/Images/cover.jpg"
});
}
return addedNovel.Entity;
return novel;
}
public async Task<Chapter> PullChapterContents(uint novelId, uint chapterNumber)
@@ -201,4 +440,25 @@ public class NovelUpdateService
await _dbContext.SaveChangesAsync();
}
public async Task<NovelUpdateRequestedEvent> QueueNovelImport(string novelUrl)
{
var importNovelRequestEvent = new NovelUpdateRequestedEvent()
{
NovelUrl = novelUrl
};
await _eventBus.Publish(importNovelRequestEvent);
return importNovelRequestEvent;
}
public async Task<ChapterPullRequestedEvent> QueueChapterPull(uint novelId, uint chapterNumber)
{
var chapterPullEvent = new ChapterPullRequestedEvent()
{
NovelId = novelId,
ChapterNumber = chapterNumber
};
await _eventBus.Publish(chapterPullEvent);
return chapterPullEvent;
}
}

View File

@@ -19,5 +19,15 @@
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "NovelService"
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true
}
}

View File

@@ -1,6 +1,6 @@
{
"subgraph": "Novels",
"http": {
"baseAddress": "http://localhost:5101/graphql"
"baseAddress": "https://localhost:7208/graphql"
}
}

View File

@@ -1,6 +1,8 @@
using System.Data;
using FictionArchive.Service.SchedulerService.Models;
using FictionArchive.Service.SchedulerService.Services;
using FictionArchive.Service.Shared.Constants;
using HotChocolate.Authorization;
using HotChocolate.Types;
using Quartz;
@@ -10,18 +12,21 @@ public class Mutation
{
[Error<DuplicateNameException>]
[Error<FormatException>]
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task<SchedulerJob> ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager)
{
return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule);
}
[Error<JobPersistenceException>]
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task<bool> RunJob(string jobKey, JobManagerService jobManager)
{
return await jobManager.TriggerJob(jobKey);
}
[Error<KeyNotFoundException>]
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task<bool> DeleteJob(string jobKey, JobManagerService jobManager)
{
bool deleted = await jobManager.DeleteJob(jobKey);

View File

@@ -1,5 +1,6 @@
using FictionArchive.Service.SchedulerService.GraphQL;
using FictionArchive.Service.SchedulerService.Services;
using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using Quartz;
@@ -11,54 +12,79 @@ public class Program
{
public static void Main(string[] args)
{
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
var builder = WebApplication.CreateBuilder(args);
// Services
builder.Services.AddDefaultGraphQl<Query, Mutation>();
builder.Services.AddDefaultGraphQl<Query, Mutation>()
.AddAuthorization();
builder.Services.AddHealthChecks();
builder.Services.AddTransient<JobManagerService>();
// Authentication & Authorization
builder.Services.AddOidcAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
#region Database
builder.Services.RegisterDbContext<SchedulerServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
builder.Services.RegisterDbContext<SchedulerServiceDbContext>(
builder.Configuration.GetConnectionString("DefaultConnection"),
skipInfrastructure: isSchemaExport);
#endregion
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
if (!isSchemaExport)
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
});
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
});
}
#endregion
#region Quartz
builder.Services.AddQuartz(opt =>
if (isSchemaExport)
{
opt.UsePersistentStore(pso =>
// Schema export mode: use in-memory store (no DB connection needed)
builder.Services.AddQuartz(opt =>
{
pso.UsePostgres(pgsql =>
{
pgsql.ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
pgsql.UseDriverDelegate<PostgreSQLDelegate>();
pgsql.TablePrefix = "quartz.qrtz_"; // Needed for Postgres due to the differing schema used
});
pso.UseNewtonsoftJsonSerializer();
opt.UseInMemoryStore();
});
});
builder.Services.AddQuartzHostedService(opt =>
}
else
{
opt.WaitForJobsToComplete = true;
});
builder.Services.AddQuartz(opt =>
{
opt.UsePersistentStore(pso =>
{
pso.UsePostgres(pgsql =>
{
pgsql.ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
pgsql.UseDriverDelegate<PostgreSQLDelegate>();
pgsql.TablePrefix = "quartz.qrtz_"; // Needed for Postgres due to the differing schema used
});
pso.UseNewtonsoftJsonSerializer();
});
});
builder.Services.AddQuartzHostedService(opt =>
{
opt.WaitForJobsToComplete = true;
});
}
#endregion
var app = builder.Build();
using (var scope = app.Services.CreateScope())
// Update database (skip in schema export mode)
if (!isSchemaExport)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SchedulerServiceDbContext>();
dbContext.UpdateDatabase();
}
@@ -66,7 +92,10 @@ public class Program
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.RunWithGraphQLCommands(args);

View File

@@ -12,5 +12,15 @@
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_SchedulerService;Username=postgres;password=postgres"
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true
}
}

View File

@@ -0,0 +1,15 @@
namespace FictionArchive.Service.Shared.Constants;
public static class AuthorizationConstants
{
public static class Roles
{
public const string Admin = "admin";
}
public static class Policies
{
public const string Admin = "Admin";
public const string User = "User";
}
}

View File

@@ -0,0 +1,168 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using FictionArchive.Service.Shared.Constants;
using FictionArchive.Service.Shared.Models.Authentication;
using System.Linq;
namespace FictionArchive.Service.Shared.Extensions;
public static class AuthenticationExtensions
{
public static IServiceCollection AddOidcAuthentication(this IServiceCollection services, IConfiguration configuration)
{
var oidcConfig = configuration.GetSection("OIDC").Get<OidcConfiguration>();
if (oidcConfig == null)
{
throw new InvalidOperationException("OIDC configuration is required but not found in app settings");
}
ValidateOidcConfiguration(oidcConfig);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = oidcConfig.Authority;
options.Audience = oidcConfig.Audience;
options.RequireHttpsMetadata = !string.IsNullOrEmpty(oidcConfig.Authority) && oidcConfig.Authority.StartsWith("https://");
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = oidcConfig.ValidateIssuer,
ValidIssuer = oidcConfig.ValidIssuer,
ValidateAudience = oidcConfig.ValidateAudience,
ValidateLifetime = oidcConfig.ValidateLifetime,
ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey,
ClockSkew = TimeSpan.FromMinutes(5)
};
options.Events = CreateLoggingJwtBearerEvents();
});
return services;
}
private static JwtBearerEvents CreateLoggingJwtBearerEvents(JwtBearerEvents? existingEvents = null)
{
return new JwtBearerEvents
{
OnMessageReceived = existingEvents?.OnMessageReceived ?? (_ => Task.CompletedTask),
OnAuthenticationFailed = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("JwtBearerAuthentication");
logger.LogWarning(context.Exception, "JWT authentication failed: {Message}", context.Exception.Message);
return existingEvents?.OnAuthenticationFailed?.Invoke(context) ?? Task.CompletedTask;
},
OnChallenge = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("JwtBearerAuthentication");
logger.LogDebug(
"JWT challenge issued. Error: {Error}, ErrorDescription: {ErrorDescription}",
context.Error,
context.ErrorDescription);
return existingEvents?.OnChallenge?.Invoke(context) ?? Task.CompletedTask;
},
OnTokenValidated = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("JwtBearerAuthentication");
logger.LogDebug(
"JWT token validated for subject: {Subject}",
context.Principal?.FindFirst("sub")?.Value ?? "unknown");
return existingEvents?.OnTokenValidated?.Invoke(context) ?? Task.CompletedTask;
}
};
}
public static IServiceCollection AddOidcCookieAuthentication(this IServiceCollection services, IConfiguration configuration, string cookieName = "fa_session")
{
var oidcConfig = configuration.GetSection("OIDC").Get<OidcConfiguration>();
if (oidcConfig == null)
{
throw new InvalidOperationException("OIDC configuration is required but not found in app settings");
}
ValidateOidcConfiguration(oidcConfig);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = oidcConfig.Authority;
options.Audience = oidcConfig.Audience;
options.RequireHttpsMetadata = !string.IsNullOrEmpty(oidcConfig.Authority) && oidcConfig.Authority.StartsWith("https://");
var cookieEvents = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// Try to get token from cookie first, then from Authorization header
if (context.Request.Cookies.ContainsKey(cookieName))
{
context.Token = context.Request.Cookies[cookieName];
}
return Task.CompletedTask;
}
};
options.Events = CreateLoggingJwtBearerEvents(cookieEvents);
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = oidcConfig.ValidateIssuer,
ValidIssuer = oidcConfig.ValidIssuer,
ValidateAudience = oidcConfig.ValidateAudience,
ValidateLifetime = oidcConfig.ValidateLifetime,
ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey,
ClockSkew = TimeSpan.FromMinutes(5)
};
});
return services;
}
public static IServiceCollection AddFictionArchiveAuthorization(this IServiceCollection services)
{
services.AddAuthorizationBuilder()
.AddPolicy(AuthorizationConstants.Policies.Admin, policy => policy.RequireRole(AuthorizationConstants.Roles.Admin))
.AddPolicy(AuthorizationConstants.Policies.User, policy => policy.RequireAuthenticatedUser());
return services;
}
private static void ValidateOidcConfiguration(OidcConfiguration config)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(config.Authority))
errors.Add("OIDC Authority is required");
if (string.IsNullOrWhiteSpace(config.ClientId))
errors.Add("OIDC ClientId is required");
if (string.IsNullOrWhiteSpace(config.Audience))
errors.Add("OIDC Audience is required");
if (!Uri.TryCreate(config.Authority, UriKind.Absolute, out var authorityUri))
errors.Add($"OIDC Authority '{config.Authority}' is not a valid URI");
else if (!authorityUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) &&
!authorityUri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
errors.Add("OIDC Authority must use HTTPS unless running on localhost");
if (errors.Any())
{
throw new InvalidOperationException($"OIDC configuration validation failed:{Environment.NewLine}{string.Join(Environment.NewLine, errors)}");
}
}
}

View File

@@ -6,16 +6,29 @@ namespace FictionArchive.Service.Shared.Extensions;
public static class DatabaseExtensions
{
public static IServiceCollection RegisterDbContext<TContext>(this IServiceCollection services,
string connectionString) where TContext : FictionArchiveDbContext
public static IServiceCollection RegisterDbContext<TContext>(
this IServiceCollection services,
string connectionString,
bool skipInfrastructure = false) where TContext : FictionArchiveDbContext
{
services.AddDbContext<TContext>(options =>
if (skipInfrastructure)
{
options.UseNpgsql(connectionString, o =>
// For schema export: use in-memory provider to allow EF Core entity discovery
services.AddDbContext<TContext>(options =>
{
o.UseNodaTime();
options.UseInMemoryDatabase($"SchemaExport_{typeof(TContext).Name}");
});
});
}
else
{
services.AddDbContext<TContext>(options =>
{
options.UseNpgsql(connectionString, o =>
{
o.UseNodaTime();
});
});
}
return services;
}
}

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="GraphQL.Server.Ui.GraphiQL" Version="8.3.3" />
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
<PackageReference Include="HotChocolate.AspNetCore.Authorization" Version="15.1.11" />
<PackageReference Include="HotChocolate.AspNetCore.CommandLine" Version="15.1.11" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
@@ -18,6 +19,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
@@ -28,6 +30,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,13 @@
namespace FictionArchive.Service.Shared.Models.Authentication;
public class OidcConfiguration
{
public string Authority { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
public string? ValidIssuer { get; set; }
public bool ValidateIssuer { get; set; } = true;
public bool ValidateAudience { get; set; } = true;
public bool ValidateLifetime { get; set; } = true;
public bool ValidateIssuerSigningKey { get; set; } = true;
}

View File

@@ -0,0 +1,22 @@
namespace FictionArchive.Service.Shared;
/// <summary>
/// Detects if the application is running in schema export mode (for HotChocolate CLI commands).
/// In this mode, infrastructure like RabbitMQ and databases should not be initialized.
/// </summary>
public static class SchemaExportDetector
{
/// <summary>
/// Checks if the current run is a schema export command.
/// </summary>
/// <param name="args">Command line arguments passed to Main()</param>
/// <returns>True if running schema export, false otherwise</returns>
public static bool IsSchemaExportMode(string[] args)
{
// HotChocolate CLI pattern: "schema export" after "--" delimiter
// Handles: dotnet run -- schema export --output schema.graphql
var normalizedArgs = args.SkipWhile(a => a == "--").ToArray();
return normalizedArgs.Length > 0 &&
normalizedArgs[0].Equals("schema", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -5,15 +5,17 @@ using FictionArchive.Service.TranslationService.Models.Enums;
using FictionArchive.Service.TranslationService.Services;
using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
using HotChocolate.Authorization;
namespace FictionArchive.Service.TranslationService.GraphQL;
public class Mutation
{
[Authorize]
public async Task<TranslationResult> TranslateText(string text, Language from, Language to, string translationEngineKey, TranslationEngineService translationEngineService)
{
var result = await translationEngineService.Translate(from, to, text, translationEngineKey);
return result;
}
}

View File

@@ -1,26 +1,41 @@
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.TranslationEngines;
using Microsoft.EntityFrameworkCore;
using HotChocolate.Authorization;
using HotChocolate.Data;
namespace FictionArchive.Service.TranslationService.GraphQL;
public class Query
{
[Authorize]
[UseFiltering]
[UseSorting]
public IEnumerable<TranslationEngineDescriptor> GetTranslationEngines(IEnumerable<ITranslationEngine> engines)
{
return engines.Select(engine => engine.Descriptor);
}
[Authorize]
[UsePaging]
[UseProjection]
[UseFiltering]
[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

@@ -1,5 +1,6 @@
using DeepL;
using FictionArchive.Common.Extensions;
using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.Shared.Services.GraphQL;
@@ -18,6 +19,8 @@ public class Program
{
public static void Main(string[] args)
{
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
var builder = WebApplication.CreateBuilder(args);
builder.AddLocalAppsettings();
@@ -25,24 +28,30 @@ public class Program
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
if (!isSchemaExport)
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<TranslationRequestCreatedEvent, TranslationRequestCreatedEventHandler>();
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<TranslationRequestCreatedEvent, TranslationRequestCreatedEventHandler>();
}
#endregion
#region Database
builder.Services.RegisterDbContext<TranslationServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
builder.Services.RegisterDbContext<TranslationServiceDbContext>(
builder.Configuration.GetConnectionString("DefaultConnection"),
skipInfrastructure: isSchemaExport);
#endregion
#region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>();
builder.Services.AddDefaultGraphQl<Query, Mutation>()
.AddAuthorization();
#endregion
@@ -55,14 +64,19 @@ public class Program
builder.Services.AddTransient<ITranslationEngine, DeepLTranslationEngine>();
builder.Services.AddTransient<TranslationEngineService>();
#endregion
// Authentication & Authorization
builder.Services.AddOidcAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
var app = builder.Build();
// Update database
using (var scope = app.Services.CreateScope())
// Update database (skip in schema export mode)
if (!isSchemaExport)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TranslationServiceDbContext>();
dbContext.UpdateDatabase();
}
@@ -70,7 +84,10 @@ public class Program
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.RunWithGraphQLCommands(args);

View File

@@ -15,5 +15,15 @@
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "TranslationService"
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true
}
}

View File

@@ -1,13 +1,38 @@
using FictionArchive.Service.UserService.Models.Database;
using FictionArchive.Service.Shared.Constants;
using FictionArchive.Service.UserService.Models.DTOs;
using FictionArchive.Service.UserService.Services;
using HotChocolate.Authorization;
namespace FictionArchive.Service.UserService.GraphQL;
public class Mutation
{
public async Task<User> RegisterUser(string username, string email, string oAuthProviderId,
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task<UserDto> RegisterUser(string username, string email, string oAuthProviderId,
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,12 +1,34 @@
using FictionArchive.Service.UserService.Models.Database;
using FictionArchive.Service.UserService.Models.DTOs;
using FictionArchive.Service.UserService.Services;
using HotChocolate.Authorization;
namespace FictionArchive.Service.UserService.GraphQL;
public class Query
{
public async Task<IQueryable<User>> GetUsers(UserManagementService userManagementService)
[Authorize]
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

@@ -1,3 +1,5 @@
using FictionArchive.Common.Extensions;
using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.UserService.GraphQL;
@@ -11,40 +13,57 @@ public class Program
{
public static void Main(string[] args)
{
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
var builder = WebApplication.CreateBuilder(args);
builder.AddLocalAppsettings();
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
if (!isSchemaExport)
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<AuthUserAddedEvent, AuthUserAddedEventHandler>();
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<AuthUserAddedEvent, AuthUserAddedEventHandler>();
}
#endregion
#region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>();
builder.Services.AddDefaultGraphQl<Query, Mutation>()
.AddAuthorization();
#endregion
builder.Services.RegisterDbContext<UserServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
builder.Services.RegisterDbContext<UserServiceDbContext>(
builder.Configuration.GetConnectionString("DefaultConnection"),
skipInfrastructure: isSchemaExport);
builder.Services.AddTransient<UserManagementService>();
builder.Services.AddHealthChecks();
// Authentication & Authorization
builder.Services.AddOidcAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
var app = builder.Build();
// Update database
using (var scope = app.Services.CreateScope())
// Update database (skip in schema export mode)
if (!isSchemaExport)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<UserServiceDbContext>();
dbContext.UpdateDatabase();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.MapHealthChecks("/healthz");
app.RunWithGraphQLCommands(args);

View File

@@ -12,5 +12,15 @@
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "UserService"
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true
}
}

10
README.md Normal file
View File

@@ -0,0 +1,10 @@
# FictionArchive
A distributed microservices-based web application for managing fiction and novel content.
## Documentation
- [README](Documentation/README.md) - Getting started and project overview
- [ARCHITECTURE](Documentation/ARCHITECTURE.md) - System architecture and design
- [CICD](Documentation/CICD.md) - CI/CD pipeline configuration
- [AGENTS](Documentation/AGENTS.md) - Development guidelines and coding standards

202
docker-compose.yml Normal file
View File

@@ -0,0 +1,202 @@
services:
# ===========================================
# Infrastructure
# ===========================================
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
rabbitmq:
image: rabbitmq:3-management-alpine
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_running"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# ===========================================
# Backend Services
# ===========================================
novel-service:
image: git.orfl.xyz/conco/fictionarchive-novel-service:latest
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_NovelService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
Novelpia__Username: ${NOVELPIA_USERNAME}
Novelpia__Password: ${NOVELPIA_PASSWORD}
NovelUpdateService__PendingImageUrl: https://files.fictionarchive.orfl.xyz/api/pendingupload.png
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
translation-service:
image: git.orfl.xyz/conco/fictionarchive-translation-service:latest
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_TranslationService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
DeepL__ApiKey: ${DEEPL_API_KEY}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
scheduler-service:
image: git.orfl.xyz/conco/fictionarchive-scheduler-service:latest
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_SchedulerService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
user-service:
image: git.orfl.xyz/conco/fictionarchive-user-service:latest
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_UserService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
authentication-service:
image: git.orfl.xyz/conco/fictionarchive-authentication-service:latest
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
rabbitmq:
condition: service_healthy
restart: unless-stopped
file-service:
image: git.orfl.xyz/conco/fictionarchive-file-service:latest
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
S3__Endpoint: ${S3_ENDPOINT:-https://s3.orfl.xyz}
S3__Bucket: ${S3_BUCKET:-fictionarchive}
S3__AccessKey: ${S3_ACCESS_KEY}
S3__SecretKey: ${S3_SECRET_KEY}
Proxy__BaseUrl: https://files.orfl.xyz/api
OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/
OIDC__ClientId: fictionarchive-files
OIDC__Audience: fictionarchive-api
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
labels:
- "traefik.enable=true"
- "traefik.http.routers.file-service.rule=Host(`files.orfl.xyz`)"
- "traefik.http.routers.file-service.entrypoints=websecure"
- "traefik.http.routers.file-service.tls.certresolver=letsencrypt"
- "traefik.http.services.file-service.loadbalancer.server.port=8080"
depends_on:
rabbitmq:
condition: service_healthy
restart: unless-stopped
# ===========================================
# API Gateway
# ===========================================
api-gateway:
image: git.orfl.xyz/conco/fictionarchive-api:latest
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/
OIDC__ClientId: fictionarchive-api
OIDC__Audience: fictionarchive-api
Cors__AllowedOrigin: https://fictionarchive.orfl.xyz
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-gateway.rule=Host(`api.fictionarchive.orfl.xyz`)"
- "traefik.http.routers.api-gateway.entrypoints=websecure"
- "traefik.http.routers.api-gateway.tls.certresolver=letsencrypt"
- "traefik.http.services.api-gateway.loadbalancer.server.port=8080"
depends_on:
- novel-service
- translation-service
- scheduler-service
- user-service
- authentication-service
- file-service
restart: unless-stopped
# ===========================================
# Frontend
# ===========================================
frontend:
image: git.orfl.xyz/conco/fictionarchive-frontend:latest
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
labels:
- "traefik.enable=true"
- "traefik.http.routers.frontend.rule=Host(`fictionarchive.orfl.xyz`)"
- "traefik.http.routers.frontend.entrypoints=websecure"
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
- "traefik.http.services.frontend.loadbalancer.server.port=80"
restart: unless-stopped
volumes:
postgres_data:
rabbitmq_data:
letsencrypt:

View File

@@ -0,0 +1,38 @@
# Dependencies
node_modules
# Build output
dist
# Development files
.env
.env.local
.env.*.local
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Logs
*.log
npm-debug.log*
# Test files
*.test.*
*.spec.*
__tests__
coverage
# Documentation
README.md
CHANGELOG.md

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 clientId = import.meta.env.VITE_OIDC_CLIENT_ID
const redirectUri = import.meta.env.VITE_OIDC_REDIRECT_URI
const postLogoutRedirectUri =
import.meta.env.VITE_OIDC_POST_LOGOUT_REDIRECT_URI ?? redirectUri
const scope = import.meta.env.VITE_OIDC_SCOPE ?? 'openid profile email'
const authority = import.meta.env.PUBLIC_OIDC_AUTHORITY;
const clientId = import.meta.env.PUBLIC_OIDC_CLIENT_ID;
const redirectUri = import.meta.env.PUBLIC_OIDC_REDIRECT_URI;
const postLogoutRedirectUri = import.meta.env.PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI ?? redirectUri;
const scope = import.meta.env.PUBLIC_OIDC_SCOPE ?? 'openid profile email';
export const isOidcConfigured =
Boolean(authority) && Boolean(clientId) && Boolean(redirectUri)
Boolean(authority) && Boolean(clientId) && Boolean(redirectUri);
function buildSettings(): UserManagerSettings | null {
if (!isOidcConfigured) return null
if (!isOidcConfigured) return null;
return {
authority: authority!,
@@ -26,11 +25,11 @@ function buildSettings(): UserManagerSettings | null {
typeof window !== 'undefined'
? new WebStorageStateStore({ store: window.localStorage })
: undefined,
}
};
}
export const userManager = (() => {
const settings = buildSettings()
if (!settings) return null
return new UserManager(settings)
})()
const settings = buildSettings();
if (!settings) return null;
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,183 @@
<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';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
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">
<!-- Back Navigation -->
<Button variant="ghost" href="/novels/{novelId}" class="-ml-2 gap-2">
<ArrowLeft class="h-4 w-4" />
Back to Novel
</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 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,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,375 @@
<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 } from '$lib/graphql/__generated__/graphql';
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';
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);
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);
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;
}
}
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
{#if novel.author.externalUrl}
<a
href={novel.author.externalUrl}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline inline-flex items-center gap-1"
>
{novel.author.name}
<ExternalLink class="h-3 w-3" />
</a>
{:else}
<span>{novel.author.name}</span>
{/if}
</p>
{/if}
</div>
<!-- Badges -->
<div class="flex flex-wrap gap-2">
<Badge class={statusColor}>{statusLabel}</Badge>
<Badge variant="outline">{languageLabel}</Badge>
</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,222 @@
<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 searchTimeout: 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);
}
// 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 = '';
onFilterChange({ ...EMPTY_FILTERS });
}
// Sync search input when filters prop changes externally
$effect(() => {
if (filters.search !== searchInput && !searchTimeout) {
searchInput = filters.search;
}
});
</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>
<!-- 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}
{#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,179 @@
<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 { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
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 });
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>
<CardTitle>Novels</CardTitle>
<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>

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>

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