45 Commits

Author SHA1 Message Date
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
6f2454329d Merge pull request 'feature/FA-18_BootstrapFrontend' (#32) from feature/FA-18_BootstrapFrontend into master
Reviewed-on: #32
2025-11-24 18:37:29 +00:00
a01250696f Merge pull request 'feature/FA-5_ImageSupport' (#31) from feature/FA-5_ImageSupport into master
Reviewed-on: #31
2025-11-24 02:17:10 +00:00
148 changed files with 17137 additions and 9817 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
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,49 @@
name: Claude PR Assistant
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude-code-action:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude PR Action
uses: markwylde/claude-code-gitea-action@v1.0.20
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
gitea_token: ${{ secrets.CLAUDE_GITEA_TOKEN }}
# Or use OAuth token instead:
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
timeout_minutes: "60"
# mode: tag # Default: responds to @claude mentions
# Optional: Restrict network access to specific domains only
# experimental_allowed_domains: |
# .anthropic.com
# .github.com
# api.github.com
# .githubusercontent.com
# bun.sh
# registry.npmjs.org
# .blob.core.windows.net

View File

@@ -0,0 +1,104 @@
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
file: fictionarchive-web/Dockerfile
push: true
build-args: |
VITE_GRAPHQL_URI=${{ vars.VITE_GRAPHQL_URI }}
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
VITE_OIDC_CLIENT_ID=${{ vars.VITE_OIDC_CLIENT_ID }}
VITE_OIDC_REDIRECT_URI=${{ vars.VITE_OIDC_REDIRECT_URI }}
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${{ vars.VITE_OIDC_POST_LOGOUT_REDIRECT_URI }}
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,6 @@
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Services;
using HotChocolate.Authorization;
using HotChocolate.Data;
using HotChocolate.Types;
@@ -7,6 +8,7 @@ namespace FictionArchive.Service.NovelService.GraphQL;
public class Query
{
[Authorize]
[UsePaging]
[UseProjection]
[UseFiltering]

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,32 @@ 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>()
.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 +76,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();
}
@@ -80,6 +94,9 @@ public class Program
app.MapHealthChecks("/healthz");
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.RunWithGraphQLCommands(args);

View File

@@ -2,6 +2,7 @@ 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;
@@ -201,4 +202,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();
}
@@ -67,6 +93,9 @@ public class Program
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,11 +5,13 @@ 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);

View File

@@ -2,12 +2,14 @@ using FictionArchive.Service.TranslationService.Models;
using FictionArchive.Service.TranslationService.Models.Database;
using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
using HotChocolate.Authorization;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.TranslationService.GraphQL;
public class Query
{
[Authorize]
[UseFiltering]
[UseSorting]
public IEnumerable<TranslationEngineDescriptor> GetTranslationEngines(IEnumerable<ITranslationEngine> engines)
@@ -15,6 +17,7 @@ public class Query
return engines.Select(engine => engine.Descriptor);
}
[Authorize]
[UsePaging]
[UseProjection]
[UseFiltering]

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
@@ -58,11 +67,16 @@ public class Program
#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();
}
@@ -71,6 +85,9 @@ public class Program
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,10 +1,13 @@
using FictionArchive.Service.Shared.Constants;
using FictionArchive.Service.UserService.Models.Database;
using FictionArchive.Service.UserService.Services;
using HotChocolate.Authorization;
namespace FictionArchive.Service.UserService.GraphQL;
public class Mutation
{
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task<User> RegisterUser(string username, string email, string oAuthProviderId,
string? inviterOAuthProviderId, UserManagementService userManagementService)
{

View File

@@ -1,10 +1,12 @@
using FictionArchive.Service.UserService.Models.Database;
using FictionArchive.Service.UserService.Services;
using HotChocolate.Authorization;
namespace FictionArchive.Service.UserService.GraphQL;
public class Query
{
[Authorize]
public async Task<IQueryable<User>> GetUsers(UserManagementService userManagementService)
{
return userManagementService.GetUsers();

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,38 +13,55 @@ 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");

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__/']
}
);

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
{
"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",
"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,115 @@
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);
}
} 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 email = $derived(
$user?.profile?.email ??
$user?.profile?.preferred_username ??
$user?.profile?.name ??
$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}>
{email}
</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,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,119 @@
<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();
function pickText(novelText?: NovelNode['name'] | NovelNode['description']) {
const texts = novelText?.texts ?? [];
const english = texts.find((t) => t.language === 'EN');
return (english ?? texts[0])?.text ?? 'No description available.';
}
const title = $derived(pickText(novel.name));
const descriptionRaw = $derived(pickText(novel.description));
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,25 @@
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
interface Props {
novelId?: string;
}
let { novelId }: Props = $props();
</script>
<Card class="shadow-md shadow-primary/10">
<CardHeader>
<CardTitle>Novel Details</CardTitle>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground">
{#if novelId}
Viewing novel ID: <code class="rounded bg-muted px-1 py-0.5">{novelId}</code>
{/if}
</p>
<p class="mt-2 text-sm text-muted-foreground">
Detail view coming soon. Select a novel to explore chapters and metadata once implemented.
</p>
</CardContent>
</Card>

View File

@@ -0,0 +1,117 @@
<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 { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
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);
const hasNextPage = $derived(pageInfo?.hasNextPage ?? false);
const novels = $derived(edges.map((edge) => edge.node).filter(Boolean));
async function fetchNovels(after: string | null = null) {
fetching = true;
error = null;
try {
const result = await client.query(NovelsDocument, { first: PAGE_SIZE, after }).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
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);
}
}
onMount(() => {
fetchNovels();
});
</script>
<div class="space-y-4">
<Card class="shadow-md shadow-primary/10">
<CardHeader>
<CardTitle>Latest Novels</CardTitle>
<p class="text-sm text-muted-foreground">Novels that have recently been updated.</p>
</CardHeader>
</Card>
{#if fetching && initialLoad}
<Card>
<CardContent>
<div class="flex items-center justify-center py-8">
<div
class="h-10 w-10 animate-spin rounded-full border-2 border-primary 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="py-4 text-sm text-destructive">Could not load novels: {error}</p>
</CardContent>
</Card>
{/if}
{#if !fetching && novels.length === 0 && !error && !initialLoad}
<Card>
<CardContent>
<p class="py-4 text-sm text-muted-foreground">
No novels found yet. Try adding content to the gateway.
</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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
sideOffset = 4,
class: className,
...restProps
}: TooltipPrimitive.ContentProps = $props();
</script>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
class={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
className
)}
{...restProps}
/>
</TooltipPrimitive.Portal>

View File

@@ -0,0 +1,785 @@
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string; }
String: { input: string; output: string; }
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
Instant: { input: any; output: any; }
UUID: { input: any; output: any; }
UnsignedInt: { input: any; output: any; }
};
/** Defines when a policy shall be executed. */
export const ApplyPolicy = {
/** After the resolver was executed. */
AfterResolver: 'AFTER_RESOLVER',
/** Before the resolver was executed. */
BeforeResolver: 'BEFORE_RESOLVER',
/** The policy is applied in the validation step before the execution. */
Validation: 'VALIDATION'
} as const;
export type ApplyPolicy = typeof ApplyPolicy[keyof typeof ApplyPolicy];
export type Chapter = {
body: LocalizationKey;
createdTime: Scalars['Instant']['output'];
id: Scalars['UnsignedInt']['output'];
images: Array<Image>;
lastUpdatedTime: Scalars['Instant']['output'];
name: LocalizationKey;
order: Scalars['UnsignedInt']['output'];
revision: Scalars['UnsignedInt']['output'];
url: Maybe<Scalars['String']['output']>;
};
export type ChapterFilterInput = {
and?: InputMaybe<Array<ChapterFilterInput>>;
body?: InputMaybe<LocalizationKeyFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
images?: InputMaybe<ListFilterInputTypeOfImageFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<LocalizationKeyFilterInput>;
or?: InputMaybe<Array<ChapterFilterInput>>;
order?: InputMaybe<UnsignedIntOperationFilterInputType>;
revision?: InputMaybe<UnsignedIntOperationFilterInputType>;
url?: InputMaybe<StringOperationFilterInput>;
};
export type ChapterPullRequestedEvent = {
chapterNumber: Scalars['UnsignedInt']['output'];
novelId: Scalars['UnsignedInt']['output'];
};
export type ChapterSortInput = {
body?: InputMaybe<LocalizationKeySortInput>;
createdTime?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
name?: InputMaybe<LocalizationKeySortInput>;
order?: InputMaybe<SortEnumType>;
revision?: InputMaybe<SortEnumType>;
url?: InputMaybe<SortEnumType>;
};
export type DeleteJobError = KeyNotFoundError;
export type DeleteJobInput = {
jobKey: Scalars['String']['input'];
};
export type DeleteJobPayload = {
boolean: Maybe<Scalars['Boolean']['output']>;
errors: Maybe<Array<DeleteJobError>>;
};
export type DuplicateNameError = Error & {
message: Scalars['String']['output'];
};
export type Error = {
message: Scalars['String']['output'];
};
export type FetchChapterContentsInput = {
chapterNumber: Scalars['UnsignedInt']['input'];
novelId: Scalars['UnsignedInt']['input'];
};
export type FetchChapterContentsPayload = {
chapterPullRequestedEvent: Maybe<ChapterPullRequestedEvent>;
};
export type FormatError = Error & {
message: Scalars['String']['output'];
};
export type Image = {
chapter: Maybe<Chapter>;
createdTime: Scalars['Instant']['output'];
id: Scalars['UUID']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
newPath: Maybe<Scalars['String']['output']>;
originalPath: Scalars['String']['output'];
};
export type ImageFilterInput = {
and?: InputMaybe<Array<ImageFilterInput>>;
chapter?: InputMaybe<ChapterFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
newPath?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<ImageFilterInput>>;
originalPath?: InputMaybe<StringOperationFilterInput>;
};
export type ImageSortInput = {
chapter?: InputMaybe<ChapterSortInput>;
createdTime?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
newPath?: InputMaybe<SortEnumType>;
originalPath?: InputMaybe<SortEnumType>;
};
export type ImportNovelInput = {
novelUrl: Scalars['String']['input'];
};
export type ImportNovelPayload = {
novelUpdateRequestedEvent: Maybe<NovelUpdateRequestedEvent>;
};
export type InstantFilterInput = {
and?: InputMaybe<Array<InstantFilterInput>>;
or?: InputMaybe<Array<InstantFilterInput>>;
};
export type JobKey = {
group: Scalars['String']['output'];
name: Scalars['String']['output'];
};
export type JobPersistenceError = Error & {
message: Scalars['String']['output'];
};
export type KeyNotFoundError = Error & {
message: Scalars['String']['output'];
};
export type KeyValuePairOfStringAndString = {
key: Scalars['String']['output'];
value: Scalars['String']['output'];
};
export const Language = {
Ch: 'CH',
En: 'EN',
Ja: 'JA',
Kr: 'KR'
} as const;
export type Language = typeof Language[keyof typeof Language];
export type LanguageOperationFilterInput = {
eq?: InputMaybe<Language>;
in?: InputMaybe<Array<Language>>;
neq?: InputMaybe<Language>;
nin?: InputMaybe<Array<Language>>;
};
export type ListFilterInputTypeOfChapterFilterInput = {
all?: InputMaybe<ChapterFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<ChapterFilterInput>;
some?: InputMaybe<ChapterFilterInput>;
};
export type ListFilterInputTypeOfImageFilterInput = {
all?: InputMaybe<ImageFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<ImageFilterInput>;
some?: InputMaybe<ImageFilterInput>;
};
export type ListFilterInputTypeOfLocalizationTextFilterInput = {
all?: InputMaybe<LocalizationTextFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<LocalizationTextFilterInput>;
some?: InputMaybe<LocalizationTextFilterInput>;
};
export type ListFilterInputTypeOfNovelFilterInput = {
all?: InputMaybe<NovelFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<NovelFilterInput>;
some?: InputMaybe<NovelFilterInput>;
};
export type ListFilterInputTypeOfNovelTagFilterInput = {
all?: InputMaybe<NovelTagFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<NovelTagFilterInput>;
some?: InputMaybe<NovelTagFilterInput>;
};
export type LocalizationKey = {
createdTime: Scalars['Instant']['output'];
id: Scalars['UUID']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
texts: Array<LocalizationText>;
};
export type LocalizationKeyFilterInput = {
and?: InputMaybe<Array<LocalizationKeyFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<LocalizationKeyFilterInput>>;
texts?: InputMaybe<ListFilterInputTypeOfLocalizationTextFilterInput>;
};
export type LocalizationKeySortInput = {
createdTime?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
};
export type LocalizationText = {
createdTime: Scalars['Instant']['output'];
id: Scalars['UUID']['output'];
language: Language;
lastUpdatedTime: Scalars['Instant']['output'];
text: Scalars['String']['output'];
translationEngine: Maybe<TranslationEngine>;
};
export type LocalizationTextFilterInput = {
and?: InputMaybe<Array<LocalizationTextFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>;
language?: InputMaybe<LanguageOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<LocalizationTextFilterInput>>;
text?: InputMaybe<StringOperationFilterInput>;
translationEngine?: InputMaybe<TranslationEngineFilterInput>;
};
export type Mutation = {
deleteJob: DeleteJobPayload;
fetchChapterContents: FetchChapterContentsPayload;
importNovel: ImportNovelPayload;
registerUser: RegisterUserPayload;
runJob: RunJobPayload;
scheduleEventJob: ScheduleEventJobPayload;
translateText: TranslateTextPayload;
};
export type MutationDeleteJobArgs = {
input: DeleteJobInput;
};
export type MutationFetchChapterContentsArgs = {
input: FetchChapterContentsInput;
};
export type MutationImportNovelArgs = {
input: ImportNovelInput;
};
export type MutationRegisterUserArgs = {
input: RegisterUserInput;
};
export type MutationRunJobArgs = {
input: RunJobInput;
};
export type MutationScheduleEventJobArgs = {
input: ScheduleEventJobInput;
};
export type MutationTranslateTextArgs = {
input: TranslateTextInput;
};
export type Novel = {
author: Person;
chapters: Array<Chapter>;
coverImage: Maybe<Image>;
createdTime: Scalars['Instant']['output'];
description: LocalizationKey;
externalId: Scalars['String']['output'];
id: Scalars['UnsignedInt']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
name: LocalizationKey;
rawLanguage: Language;
rawStatus: NovelStatus;
source: Source;
statusOverride: Maybe<NovelStatus>;
tags: Array<NovelTag>;
url: Scalars['String']['output'];
};
export type NovelFilterInput = {
and?: InputMaybe<Array<NovelFilterInput>>;
author?: InputMaybe<PersonFilterInput>;
chapters?: InputMaybe<ListFilterInputTypeOfChapterFilterInput>;
coverImage?: InputMaybe<ImageFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>;
description?: InputMaybe<LocalizationKeyFilterInput>;
externalId?: InputMaybe<StringOperationFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<LocalizationKeyFilterInput>;
or?: InputMaybe<Array<NovelFilterInput>>;
rawLanguage?: InputMaybe<LanguageOperationFilterInput>;
rawStatus?: InputMaybe<NovelStatusOperationFilterInput>;
source?: InputMaybe<SourceFilterInput>;
statusOverride?: InputMaybe<NullableOfNovelStatusOperationFilterInput>;
tags?: InputMaybe<ListFilterInputTypeOfNovelTagFilterInput>;
url?: InputMaybe<StringOperationFilterInput>;
};
export type NovelSortInput = {
author?: InputMaybe<PersonSortInput>;
coverImage?: InputMaybe<ImageSortInput>;
createdTime?: InputMaybe<SortEnumType>;
description?: InputMaybe<LocalizationKeySortInput>;
externalId?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
name?: InputMaybe<LocalizationKeySortInput>;
rawLanguage?: InputMaybe<SortEnumType>;
rawStatus?: InputMaybe<SortEnumType>;
source?: InputMaybe<SourceSortInput>;
statusOverride?: InputMaybe<SortEnumType>;
url?: InputMaybe<SortEnumType>;
};
export const NovelStatus = {
Abandoned: 'ABANDONED',
Completed: 'COMPLETED',
Hiatus: 'HIATUS',
InProgress: 'IN_PROGRESS',
Unknown: 'UNKNOWN'
} as const;
export type NovelStatus = typeof NovelStatus[keyof typeof NovelStatus];
export type NovelStatusOperationFilterInput = {
eq?: InputMaybe<NovelStatus>;
in?: InputMaybe<Array<NovelStatus>>;
neq?: InputMaybe<NovelStatus>;
nin?: InputMaybe<Array<NovelStatus>>;
};
export type NovelTag = {
createdTime: Scalars['Instant']['output'];
displayName: LocalizationKey;
id: Scalars['UnsignedInt']['output'];
key: Scalars['String']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
novels: Array<Novel>;
source: Maybe<Source>;
tagType: TagType;
};
export type NovelTagFilterInput = {
and?: InputMaybe<Array<NovelTagFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
displayName?: InputMaybe<LocalizationKeyFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
key?: InputMaybe<StringOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
novels?: InputMaybe<ListFilterInputTypeOfNovelFilterInput>;
or?: InputMaybe<Array<NovelTagFilterInput>>;
source?: InputMaybe<SourceFilterInput>;
tagType?: InputMaybe<TagTypeOperationFilterInput>;
};
export type NovelUpdateRequestedEvent = {
novelUrl: Scalars['String']['output'];
};
/** A connection to a list of items. */
export type NovelsConnection = {
/** A list of edges. */
edges: Maybe<Array<NovelsEdge>>;
/** A flattened list of the nodes. */
nodes: Maybe<Array<Novel>>;
/** Information to aid in pagination. */
pageInfo: PageInfo;
};
/** An edge in a connection. */
export type NovelsEdge = {
/** A cursor for use in pagination. */
cursor: Scalars['String']['output'];
/** The item at the end of the edge. */
node: Novel;
};
export type NullableOfNovelStatusOperationFilterInput = {
eq?: InputMaybe<NovelStatus>;
in?: InputMaybe<Array<InputMaybe<NovelStatus>>>;
neq?: InputMaybe<NovelStatus>;
nin?: InputMaybe<Array<InputMaybe<NovelStatus>>>;
};
/** Information about pagination in a connection. */
export type PageInfo = {
/** When paginating forwards, the cursor to continue. */
endCursor: Maybe<Scalars['String']['output']>;
/** Indicates whether more edges exist following the set defined by the clients arguments. */
hasNextPage: Scalars['Boolean']['output'];
/** Indicates whether more edges exist prior the set defined by the clients arguments. */
hasPreviousPage: Scalars['Boolean']['output'];
/** When paginating backwards, the cursor to continue. */
startCursor: Maybe<Scalars['String']['output']>;
};
export type Person = {
createdTime: Scalars['Instant']['output'];
externalUrl: Maybe<Scalars['String']['output']>;
id: Scalars['UnsignedInt']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
name: LocalizationKey;
};
export type PersonFilterInput = {
and?: InputMaybe<Array<PersonFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
externalUrl?: InputMaybe<StringOperationFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<LocalizationKeyFilterInput>;
or?: InputMaybe<Array<PersonFilterInput>>;
};
export type PersonSortInput = {
createdTime?: InputMaybe<SortEnumType>;
externalUrl?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
name?: InputMaybe<LocalizationKeySortInput>;
};
export type Query = {
jobs: Array<SchedulerJob>;
novels: Maybe<NovelsConnection>;
translationEngines: Array<TranslationEngineDescriptor>;
translationRequests: Maybe<TranslationRequestsConnection>;
users: Array<User>;
};
export type QueryNovelsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
order?: InputMaybe<Array<NovelSortInput>>;
where?: InputMaybe<NovelFilterInput>;
};
export type QueryTranslationEnginesArgs = {
order?: InputMaybe<Array<TranslationEngineDescriptorSortInput>>;
where?: InputMaybe<TranslationEngineDescriptorFilterInput>;
};
export type QueryTranslationRequestsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
order?: InputMaybe<Array<TranslationRequestSortInput>>;
where?: InputMaybe<TranslationRequestFilterInput>;
};
export type RegisterUserInput = {
email: Scalars['String']['input'];
inviterOAuthProviderId?: InputMaybe<Scalars['String']['input']>;
oAuthProviderId: Scalars['String']['input'];
username: Scalars['String']['input'];
};
export type RegisterUserPayload = {
user: Maybe<User>;
};
export type RunJobError = JobPersistenceError;
export type RunJobInput = {
jobKey: Scalars['String']['input'];
};
export type RunJobPayload = {
boolean: Maybe<Scalars['Boolean']['output']>;
errors: Maybe<Array<RunJobError>>;
};
export type ScheduleEventJobError = DuplicateNameError | FormatError;
export type ScheduleEventJobInput = {
cronSchedule: Scalars['String']['input'];
description: Scalars['String']['input'];
eventData: Scalars['String']['input'];
eventType: Scalars['String']['input'];
key: Scalars['String']['input'];
};
export type ScheduleEventJobPayload = {
errors: Maybe<Array<ScheduleEventJobError>>;
schedulerJob: Maybe<SchedulerJob>;
};
export type SchedulerJob = {
cronSchedule: Array<Scalars['String']['output']>;
description: Scalars['String']['output'];
jobData: Array<KeyValuePairOfStringAndString>;
jobKey: JobKey;
jobTypeName: Scalars['String']['output'];
};
export const SortEnumType = {
Asc: 'ASC',
Desc: 'DESC'
} as const;
export type SortEnumType = typeof SortEnumType[keyof typeof SortEnumType];
export type Source = {
createdTime: Scalars['Instant']['output'];
id: Scalars['UnsignedInt']['output'];
key: Scalars['String']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
name: Scalars['String']['output'];
url: Scalars['String']['output'];
};
export type SourceFilterInput = {
and?: InputMaybe<Array<SourceFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
key?: InputMaybe<StringOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<SourceFilterInput>>;
url?: InputMaybe<StringOperationFilterInput>;
};
export type SourceSortInput = {
createdTime?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
key?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
name?: InputMaybe<SortEnumType>;
url?: InputMaybe<SortEnumType>;
};
export type StringOperationFilterInput = {
and?: InputMaybe<Array<StringOperationFilterInput>>;
contains?: InputMaybe<Scalars['String']['input']>;
endsWith?: InputMaybe<Scalars['String']['input']>;
eq?: InputMaybe<Scalars['String']['input']>;
in?: InputMaybe<Array<InputMaybe<Scalars['String']['input']>>>;
ncontains?: InputMaybe<Scalars['String']['input']>;
nendsWith?: InputMaybe<Scalars['String']['input']>;
neq?: InputMaybe<Scalars['String']['input']>;
nin?: InputMaybe<Array<InputMaybe<Scalars['String']['input']>>>;
nstartsWith?: InputMaybe<Scalars['String']['input']>;
or?: InputMaybe<Array<StringOperationFilterInput>>;
startsWith?: InputMaybe<Scalars['String']['input']>;
};
export const TagType = {
External: 'EXTERNAL',
Genre: 'GENRE',
System: 'SYSTEM',
UserDefined: 'USER_DEFINED'
} as const;
export type TagType = typeof TagType[keyof typeof TagType];
export type TagTypeOperationFilterInput = {
eq?: InputMaybe<TagType>;
in?: InputMaybe<Array<TagType>>;
neq?: InputMaybe<TagType>;
nin?: InputMaybe<Array<TagType>>;
};
export type TranslateTextInput = {
from: Language;
text: Scalars['String']['input'];
to: Language;
translationEngineKey: Scalars['String']['input'];
};
export type TranslateTextPayload = {
translationResult: Maybe<TranslationResult>;
};
export type TranslationEngine = {
createdTime: Scalars['Instant']['output'];
id: Scalars['UnsignedInt']['output'];
key: Scalars['String']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
};
export type TranslationEngineDescriptor = {
displayName: Scalars['String']['output'];
key: Scalars['String']['output'];
};
export type TranslationEngineDescriptorFilterInput = {
and?: InputMaybe<Array<TranslationEngineDescriptorFilterInput>>;
displayName?: InputMaybe<StringOperationFilterInput>;
key?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<TranslationEngineDescriptorFilterInput>>;
};
export type TranslationEngineDescriptorSortInput = {
displayName?: InputMaybe<SortEnumType>;
key?: InputMaybe<SortEnumType>;
};
export type TranslationEngineFilterInput = {
and?: InputMaybe<Array<TranslationEngineFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
key?: InputMaybe<StringOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<TranslationEngineFilterInput>>;
};
export type TranslationRequest = {
billedCharacterCount: Scalars['UnsignedInt']['output'];
createdTime: Scalars['Instant']['output'];
from: Language;
id: Scalars['UUID']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
originalText: Scalars['String']['output'];
status: TranslationRequestStatus;
to: Language;
translatedText: Maybe<Scalars['String']['output']>;
translationEngineKey: Scalars['String']['output'];
};
export type TranslationRequestFilterInput = {
and?: InputMaybe<Array<TranslationRequestFilterInput>>;
billedCharacterCount?: InputMaybe<UnsignedIntOperationFilterInputType>;
createdTime?: InputMaybe<InstantFilterInput>;
from?: InputMaybe<LanguageOperationFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<TranslationRequestFilterInput>>;
originalText?: InputMaybe<StringOperationFilterInput>;
status?: InputMaybe<TranslationRequestStatusOperationFilterInput>;
to?: InputMaybe<LanguageOperationFilterInput>;
translatedText?: InputMaybe<StringOperationFilterInput>;
translationEngineKey?: InputMaybe<StringOperationFilterInput>;
};
export type TranslationRequestSortInput = {
billedCharacterCount?: InputMaybe<SortEnumType>;
createdTime?: InputMaybe<SortEnumType>;
from?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
originalText?: InputMaybe<SortEnumType>;
status?: InputMaybe<SortEnumType>;
to?: InputMaybe<SortEnumType>;
translatedText?: InputMaybe<SortEnumType>;
translationEngineKey?: InputMaybe<SortEnumType>;
};
export const TranslationRequestStatus = {
Failed: 'FAILED',
Pending: 'PENDING',
Success: 'SUCCESS'
} as const;
export type TranslationRequestStatus = typeof TranslationRequestStatus[keyof typeof TranslationRequestStatus];
export type TranslationRequestStatusOperationFilterInput = {
eq?: InputMaybe<TranslationRequestStatus>;
in?: InputMaybe<Array<TranslationRequestStatus>>;
neq?: InputMaybe<TranslationRequestStatus>;
nin?: InputMaybe<Array<TranslationRequestStatus>>;
};
/** A connection to a list of items. */
export type TranslationRequestsConnection = {
/** A list of edges. */
edges: Maybe<Array<TranslationRequestsEdge>>;
/** A flattened list of the nodes. */
nodes: Maybe<Array<TranslationRequest>>;
/** Information to aid in pagination. */
pageInfo: PageInfo;
};
/** An edge in a connection. */
export type TranslationRequestsEdge = {
/** A cursor for use in pagination. */
cursor: Scalars['String']['output'];
/** The item at the end of the edge. */
node: TranslationRequest;
};
export type TranslationResult = {
billedCharacterCount: Scalars['UnsignedInt']['output'];
from: Language;
originalText: Scalars['String']['output'];
status: TranslationRequestStatus;
to: Language;
translatedText: Maybe<Scalars['String']['output']>;
translationEngineKey: Scalars['String']['output'];
};
export type UnsignedIntOperationFilterInputType = {
eq?: InputMaybe<Scalars['UnsignedInt']['input']>;
gt?: InputMaybe<Scalars['UnsignedInt']['input']>;
gte?: InputMaybe<Scalars['UnsignedInt']['input']>;
in?: InputMaybe<Array<InputMaybe<Scalars['UnsignedInt']['input']>>>;
lt?: InputMaybe<Scalars['UnsignedInt']['input']>;
lte?: InputMaybe<Scalars['UnsignedInt']['input']>;
neq?: InputMaybe<Scalars['UnsignedInt']['input']>;
ngt?: InputMaybe<Scalars['UnsignedInt']['input']>;
ngte?: InputMaybe<Scalars['UnsignedInt']['input']>;
nin?: InputMaybe<Array<InputMaybe<Scalars['UnsignedInt']['input']>>>;
nlt?: InputMaybe<Scalars['UnsignedInt']['input']>;
nlte?: InputMaybe<Scalars['UnsignedInt']['input']>;
};
export type User = {
createdTime: Scalars['Instant']['output'];
disabled: Scalars['Boolean']['output'];
email: Scalars['String']['output'];
id: Scalars['UUID']['output'];
inviter: Maybe<User>;
lastUpdatedTime: Scalars['Instant']['output'];
oAuthProviderId: Scalars['String']['output'];
username: Scalars['String']['output'];
};
export type UuidOperationFilterInput = {
eq?: InputMaybe<Scalars['UUID']['input']>;
gt?: InputMaybe<Scalars['UUID']['input']>;
gte?: InputMaybe<Scalars['UUID']['input']>;
in?: InputMaybe<Array<InputMaybe<Scalars['UUID']['input']>>>;
lt?: InputMaybe<Scalars['UUID']['input']>;
lte?: InputMaybe<Scalars['UUID']['input']>;
neq?: InputMaybe<Scalars['UUID']['input']>;
ngt?: InputMaybe<Scalars['UUID']['input']>;
ngte?: InputMaybe<Scalars['UUID']['input']>;
nin?: InputMaybe<Array<InputMaybe<Scalars['UUID']['input']>>>;
nlt?: InputMaybe<Scalars['UUID']['input']>;
nlte?: InputMaybe<Scalars['UUID']['input']>;
};
export type NovelsQueryVariables = Exact<{
first?: InputMaybe<Scalars['Int']['input']>;
after?: InputMaybe<Scalars['String']['input']>;
}>;
export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id: any, url: string, rawStatus: NovelStatus, lastUpdatedTime: any, name: { texts: Array<{ language: Language, text: string }> }, description: { texts: Array<{ language: Language, text: string }> }, coverImage: { originalPath: string, newPath: string | null } | null, chapters: Array<{ order: any, name: { texts: Array<{ language: Language, text: string }> } }> } }> | null, pageInfo: { hasNextPage: boolean, endCursor: string | null } } | null };
export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"texts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"description"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"texts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"originalPath"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"texts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode<NovelsQuery, NovelsQueryVariables>;

View File

@@ -0,0 +1,21 @@
import { Client, cacheExchange, fetchExchange } from '@urql/core';
import { get } from 'svelte/store';
import { user } from '../auth/authStore';
export function createClient() {
return new Client({
url: import.meta.env.PUBLIC_GRAPHQL_URI,
exchanges: [cacheExchange, fetchExchange],
fetchOptions: () => {
const currentUser = get(user);
return {
headers: currentUser?.access_token
? { Authorization: `Bearer ${currentUser.access_token}` }
: {},
};
},
});
}
// Singleton for use in components
export const client = createClient();

View File

@@ -21,6 +21,17 @@ query Novels($first: Int, $after: String) {
originalPath
newPath
}
rawStatus
lastUpdatedTime
chapters {
order
name {
texts {
language
text
}
}
}
}
}
pageInfo {

View File

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

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