Compare commits
35 Commits
43d5ada7fb
...
feature/FA
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c60aaf2bdb | ||
|
|
b2f4548807 | ||
|
|
8d6f0d6cfd | ||
| bc83bffb4b | |||
|
|
75e96cbee5 | ||
|
|
9c82d648cd | ||
|
|
78612ea29d | ||
| 4412a1f658 | |||
| 12e3c5dfdd | |||
|
|
b71d9031e1 | ||
|
|
09ebdb1b2a | ||
|
|
4635ed1b4e | ||
|
|
920fd00910 | ||
|
|
0d9f788678 | ||
|
|
0938c16a76 | ||
|
|
f25cbc1a04 | ||
|
|
078eaf5237 | ||
|
|
b9115d78a9 | ||
|
|
7e94f06853 | ||
|
|
50263109ab | ||
|
|
6ebfe81ae3 | ||
|
|
80aac63f7d | ||
|
|
adc99c7000 | ||
| 30cc89242d | |||
|
|
15a8185621 | ||
|
|
0180a58084 | ||
|
|
573f3fc7b0 | ||
|
|
cdc2176e35 | ||
|
|
e9eaf1569b | ||
|
|
ba99642e97 | ||
|
|
c6d794aabc | ||
|
|
62e7e20f94 | ||
| 9e1792e4d0 | |||
|
|
200bdaabed | ||
|
|
caa36648e2 |
166
.gitea/workflows/build-gateway.yml
Normal file
166
.gitea/workflows/build-gateway.yml
Normal 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
|
||||||
74
.gitea/workflows/build.yml
Normal file
74
.gitea/workflows/build.yml
Normal 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
|
||||||
104
.gitea/workflows/release.yml
Normal file
104
.gitea/workflows/release.yml
Normal 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
|
||||||
405
Documentation/ARCHITECTURE.md
Normal file
405
Documentation/ARCHITECTURE.md
Normal 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
297
Documentation/CICD.md
Normal 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
187
Documentation/README.md
Normal 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
|
||||||
@@ -7,15 +7,23 @@ EXPOSE 8081
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY ["FictionArchive.API/FictionArchive.API.csproj", "FictionArchive.API/"]
|
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"
|
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"
|
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
|
FROM build AS publish
|
||||||
ARG BUILD_CONFIGURATION=Release
|
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
|
FROM base AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
|
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
|
||||||
<PackageReference Include="HotChocolate.Fusion" Version="15.1.11" />
|
<PackageReference Include="HotChocolate.Fusion" Version="15.1.11" />
|
||||||
<PackageReference Include="HotChocolate.Types.Scalars" 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">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
@@ -20,11 +21,12 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
|
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Builds the Fusion graph file before building the application itself -->
|
<!-- Builds the Fusion graph file before building the application itself (skipped in CI) -->
|
||||||
<Target Name="RunFusionBuild" BeforeTargets="BeforeBuild">
|
<Target Name="RunFusionBuild" BeforeTargets="BeforeBuild" Condition="'$(SkipFusionBuild)' != 'true'">
|
||||||
<Exec Command="python build_gateway.py" WorkingDirectory="$(ProjectDir)" />
|
<Exec Command="python build_gateway.py $(FusionBuildArgs)" WorkingDirectory="$(ProjectDir)" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -12,32 +12,44 @@ public class Program
|
|||||||
|
|
||||||
#region Fusion Gateway
|
#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
|
builder.Services
|
||||||
.AddFusionGatewayServer()
|
.AddFusionGatewayServer()
|
||||||
.ConfigureFromFile("gateway.fgp")
|
.ConfigureFromFile("gateway.fgp")
|
||||||
.CoreBuilder.ApplySaneDefaults();
|
.CoreBuilder.ApplySaneDefaults();
|
||||||
|
|
||||||
|
builder.Services.AddOidcAuthentication(builder.Configuration);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
var allowedOrigin = builder.Configuration["Cors:AllowedOrigin"] ?? "http://localhost:4321";
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("AllowAllOrigins",
|
options.AddPolicy("AllowFictionArchiveOrigins",
|
||||||
builder =>
|
policyBuilder =>
|
||||||
{
|
{
|
||||||
builder.AllowAnyOrigin()
|
policyBuilder.WithOrigins(allowedOrigin)
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowAnyHeader();
|
.AllowAnyHeader()
|
||||||
|
.AllowCredentials();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseCors("AllowAllOrigins");
|
app.UseCors("AllowFictionArchiveOrigins");
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
|
app.UseHeaderPropagation();
|
||||||
|
|
||||||
app.MapGraphQL();
|
app.MapGraphQL();
|
||||||
|
|
||||||
app.RunWithGraphQLCommands(args);
|
app.RunWithGraphQLCommands(args);
|
||||||
|
|||||||
@@ -5,5 +5,18 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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 subprocess
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# ----------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ----------------------------------------
|
|
||||||
|
|
||||||
def run(cmd, cwd=None):
|
def run(cmd, cwd=None):
|
||||||
"""Run a command and exit on failure."""
|
"""Run a command and exit on failure."""
|
||||||
@@ -19,7 +29,7 @@ def run(cmd, cwd=None):
|
|||||||
|
|
||||||
def load_skip_list(skip_file: Path):
|
def load_skip_list(skip_file: Path):
|
||||||
if not skip_file.exists():
|
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()
|
return set()
|
||||||
|
|
||||||
lines = skip_file.read_text().splitlines()
|
lines = skip_file.read_text().splitlines()
|
||||||
@@ -53,7 +63,7 @@ print("----------------------------------------")
|
|||||||
|
|
||||||
service_dirs = [
|
service_dirs = [
|
||||||
d for d in services_dir.glob("FictionArchive.Service.*")
|
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 = []
|
selected_services = []
|
||||||
|
|||||||
@@ -9,14 +9,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" 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" />
|
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||||
</ItemGroup>
|
</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>
|
</Project>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Web;
|
|||||||
using Amazon.S3;
|
using Amazon.S3;
|
||||||
using Amazon.S3.Model;
|
using Amazon.S3.Model;
|
||||||
using FictionArchive.Service.FileService.Models;
|
using FictionArchive.Service.FileService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -10,6 +11,7 @@ namespace FictionArchive.Service.FileService.Controllers
|
|||||||
{
|
{
|
||||||
[Route("api/{*path}")]
|
[Route("api/{*path}")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
public class S3ProxyController : ControllerBase
|
public class S3ProxyController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly AmazonS3Client _amazonS3Client;
|
private readonly AmazonS3Client _amazonS3Client;
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ EXPOSE 8081
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY ["FictionArchive.Service.ImageService/FictionArchive.Service.ImageService.csproj", "FictionArchive.Service.ImageService/"]
|
COPY ["FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj", "FictionArchive.Service.FileService/"]
|
||||||
RUN dotnet restore "FictionArchive.Service.ImageService/FictionArchive.Service.ImageService.csproj"
|
RUN dotnet restore "FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj"
|
||||||
COPY . .
|
COPY . .
|
||||||
WORKDIR "/src/FictionArchive.Service.ImageService"
|
WORKDIR "/src/FictionArchive.Service.FileService"
|
||||||
RUN dotnet build "./FictionArchive.Service.ImageService.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
RUN dotnet build "./FictionArchive.Service.FileService.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
FROM build AS publish
|
FROM build AS publish
|
||||||
ARG BUILD_CONFIGURATION=Release
|
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
|
FROM base AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=publish /app/publish .
|
COPY --from=publish /app/publish .
|
||||||
ENTRYPOINT ["dotnet", "FictionArchive.Service.ImageService.dll"]
|
ENTRYPOINT ["dotnet", "FictionArchive.Service.FileService.dll"]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<PackageReference Include="AWSSDK.S3" Version="4.0.13.1" />
|
<PackageReference Include="AWSSDK.S3" Version="4.0.13.1" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
|
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ public class Program
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
// Add authentication with cookie support
|
||||||
|
builder.Services.AddOidcCookieAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddFictionArchiveAuthorization();
|
||||||
|
|
||||||
builder.Services.Configure<ProxyConfiguration>(builder.Configuration.GetSection("ProxyConfiguration"));
|
builder.Services.Configure<ProxyConfiguration>(builder.Configuration.GetSection("ProxyConfiguration"));
|
||||||
|
|
||||||
// Add S3 Client
|
// Add S3 Client
|
||||||
@@ -60,6 +64,9 @@ public class Program
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|||||||
@@ -18,5 +18,15 @@
|
|||||||
"AccessKey": "REPLACE_ME",
|
"AccessKey": "REPLACE_ME",
|
||||||
"SecretKey": "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": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,32 +6,24 @@ using FictionArchive.Service.NovelService.Models.SourceAdapters;
|
|||||||
using FictionArchive.Service.NovelService.Services;
|
using FictionArchive.Service.NovelService.Services;
|
||||||
using FictionArchive.Service.NovelService.Services.SourceAdapters;
|
using FictionArchive.Service.NovelService.Services.SourceAdapters;
|
||||||
using FictionArchive.Service.Shared.Services.EventBus;
|
using FictionArchive.Service.Shared.Services.EventBus;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace FictionArchive.Service.NovelService.GraphQL;
|
namespace FictionArchive.Service.NovelService.GraphQL;
|
||||||
|
|
||||||
public class Mutation
|
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()
|
return await service.QueueNovelImport(novelUrl);
|
||||||
{
|
|
||||||
NovelUrl = novelUrl
|
|
||||||
};
|
|
||||||
await eventBus.Publish(importNovelRequestEvent);
|
|
||||||
return importNovelRequestEvent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
public async Task<ChapterPullRequestedEvent> FetchChapterContents(uint novelId,
|
public async Task<ChapterPullRequestedEvent> FetchChapterContents(uint novelId,
|
||||||
uint chapterNumber,
|
uint chapterNumber,
|
||||||
IEventBus eventBus)
|
NovelUpdateService service)
|
||||||
{
|
{
|
||||||
var chapterPullEvent = new ChapterPullRequestedEvent()
|
return await service.QueueChapterPull(novelId, chapterNumber);
|
||||||
{
|
|
||||||
NovelId = novelId,
|
|
||||||
ChapterNumber = chapterNumber
|
|
||||||
};
|
|
||||||
await eventBus.Publish(chapterPullEvent);
|
|
||||||
return chapterPullEvent;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using FictionArchive.Service.NovelService.Models.Novels;
|
using FictionArchive.Service.NovelService.Models.Novels;
|
||||||
using FictionArchive.Service.NovelService.Services;
|
using FictionArchive.Service.NovelService.Services;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
using HotChocolate.Data;
|
using HotChocolate.Data;
|
||||||
using HotChocolate.Types;
|
using HotChocolate.Types;
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ namespace FictionArchive.Service.NovelService.GraphQL;
|
|||||||
|
|
||||||
public class Query
|
public class Query
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[UsePaging]
|
[UsePaging]
|
||||||
[UseProjection]
|
[UseProjection]
|
||||||
[UseFiltering]
|
[UseFiltering]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using FictionArchive.Service.NovelService.Services;
|
|||||||
using FictionArchive.Service.NovelService.Services.EventHandlers;
|
using FictionArchive.Service.NovelService.Services.EventHandlers;
|
||||||
using FictionArchive.Service.NovelService.Services.SourceAdapters;
|
using FictionArchive.Service.NovelService.Services.SourceAdapters;
|
||||||
using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
|
using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
|
||||||
|
using FictionArchive.Service.Shared;
|
||||||
using FictionArchive.Service.Shared.Extensions;
|
using FictionArchive.Service.Shared.Extensions;
|
||||||
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
||||||
using FictionArchive.Service.Shared.Services.GraphQL;
|
using FictionArchive.Service.Shared.Services.GraphQL;
|
||||||
@@ -17,6 +18,8 @@ public class Program
|
|||||||
{
|
{
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.AddLocalAppsettings();
|
builder.AddLocalAppsettings();
|
||||||
|
|
||||||
@@ -24,26 +27,32 @@ public class Program
|
|||||||
|
|
||||||
#region Event Bus
|
#region Event Bus
|
||||||
|
|
||||||
builder.Services.AddRabbitMQ(opt =>
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
builder.Services.AddRabbitMQ(opt =>
|
||||||
})
|
{
|
||||||
.Subscribe<TranslationRequestCompletedEvent, TranslationRequestCompletedEventHandler>()
|
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
||||||
.Subscribe<NovelUpdateRequestedEvent, NovelUpdateRequestedEventHandler>()
|
})
|
||||||
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>()
|
.Subscribe<TranslationRequestCompletedEvent, TranslationRequestCompletedEventHandler>()
|
||||||
.Subscribe<FileUploadRequestStatusUpdateEvent, FileUploadRequestStatusUpdateEventHandler>();
|
.Subscribe<NovelUpdateRequestedEvent, NovelUpdateRequestedEventHandler>()
|
||||||
|
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>()
|
||||||
|
.Subscribe<FileUploadRequestStatusUpdateEvent, FileUploadRequestStatusUpdateEventHandler>();
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region GraphQL
|
#region GraphQL
|
||||||
|
|
||||||
builder.Services.AddDefaultGraphQl<Query, Mutation>();
|
builder.Services.AddDefaultGraphQl<Query, Mutation>()
|
||||||
|
.AddAuthorization();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Database
|
#region Database
|
||||||
|
|
||||||
builder.Services.RegisterDbContext<NovelServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
|
builder.Services.RegisterDbContext<NovelServiceDbContext>(
|
||||||
|
builder.Configuration.GetConnectionString("DefaultConnection"),
|
||||||
|
skipInfrastructure: isSchemaExport);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -67,11 +76,16 @@ public class Program
|
|||||||
|
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
|
|
||||||
|
// Authentication & Authorization
|
||||||
|
builder.Services.AddOidcAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddFictionArchiveAuthorization();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Update database
|
// Update database (skip in schema export mode)
|
||||||
using (var scope = app.Services.CreateScope())
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<NovelServiceDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<NovelServiceDbContext>();
|
||||||
dbContext.UpdateDatabase();
|
dbContext.UpdateDatabase();
|
||||||
}
|
}
|
||||||
@@ -79,7 +93,10 @@ public class Program
|
|||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapGraphQL();
|
app.MapGraphQL();
|
||||||
|
|
||||||
app.RunWithGraphQLCommands(args);
|
app.RunWithGraphQLCommands(args);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FictionArchive.Service.FileService.IntegrationEvents;
|
|||||||
using FictionArchive.Service.NovelService.Models.Configuration;
|
using FictionArchive.Service.NovelService.Models.Configuration;
|
||||||
using FictionArchive.Service.NovelService.Models.Enums;
|
using FictionArchive.Service.NovelService.Models.Enums;
|
||||||
using FictionArchive.Service.NovelService.Models.Images;
|
using FictionArchive.Service.NovelService.Models.Images;
|
||||||
|
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
|
||||||
using FictionArchive.Service.NovelService.Models.Localization;
|
using FictionArchive.Service.NovelService.Models.Localization;
|
||||||
using FictionArchive.Service.NovelService.Models.Novels;
|
using FictionArchive.Service.NovelService.Models.Novels;
|
||||||
using FictionArchive.Service.NovelService.Models.SourceAdapters;
|
using FictionArchive.Service.NovelService.Models.SourceAdapters;
|
||||||
@@ -201,4 +202,25 @@ public class NovelUpdateService
|
|||||||
|
|
||||||
await _dbContext.SaveChangesAsync();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,15 @@
|
|||||||
"ConnectionString": "amqp://localhost",
|
"ConnectionString": "amqp://localhost",
|
||||||
"ClientIdentifier": "NovelService"
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"subgraph": "Novels",
|
"subgraph": "Novels",
|
||||||
"http": {
|
"http": {
|
||||||
"baseAddress": "http://localhost:5101/graphql"
|
"baseAddress": "https://localhost:7208/graphql"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using FictionArchive.Service.SchedulerService.Models;
|
using FictionArchive.Service.SchedulerService.Models;
|
||||||
using FictionArchive.Service.SchedulerService.Services;
|
using FictionArchive.Service.SchedulerService.Services;
|
||||||
|
using FictionArchive.Service.Shared.Constants;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
using HotChocolate.Types;
|
using HotChocolate.Types;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
@@ -10,18 +12,21 @@ public class Mutation
|
|||||||
{
|
{
|
||||||
[Error<DuplicateNameException>]
|
[Error<DuplicateNameException>]
|
||||||
[Error<FormatException>]
|
[Error<FormatException>]
|
||||||
|
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
|
||||||
public async Task<SchedulerJob> ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager)
|
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);
|
return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Error<JobPersistenceException>]
|
[Error<JobPersistenceException>]
|
||||||
|
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
|
||||||
public async Task<bool> RunJob(string jobKey, JobManagerService jobManager)
|
public async Task<bool> RunJob(string jobKey, JobManagerService jobManager)
|
||||||
{
|
{
|
||||||
return await jobManager.TriggerJob(jobKey);
|
return await jobManager.TriggerJob(jobKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Error<KeyNotFoundException>]
|
[Error<KeyNotFoundException>]
|
||||||
|
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
|
||||||
public async Task<bool> DeleteJob(string jobKey, JobManagerService jobManager)
|
public async Task<bool> DeleteJob(string jobKey, JobManagerService jobManager)
|
||||||
{
|
{
|
||||||
bool deleted = await jobManager.DeleteJob(jobKey);
|
bool deleted = await jobManager.DeleteJob(jobKey);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FictionArchive.Service.SchedulerService.GraphQL;
|
using FictionArchive.Service.SchedulerService.GraphQL;
|
||||||
using FictionArchive.Service.SchedulerService.Services;
|
using FictionArchive.Service.SchedulerService.Services;
|
||||||
|
using FictionArchive.Service.Shared;
|
||||||
using FictionArchive.Service.Shared.Extensions;
|
using FictionArchive.Service.Shared.Extensions;
|
||||||
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
@@ -11,54 +12,79 @@ public class Program
|
|||||||
{
|
{
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
builder.Services.AddDefaultGraphQl<Query, Mutation>();
|
builder.Services.AddDefaultGraphQl<Query, Mutation>()
|
||||||
|
.AddAuthorization();
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
builder.Services.AddTransient<JobManagerService>();
|
builder.Services.AddTransient<JobManagerService>();
|
||||||
|
|
||||||
|
// Authentication & Authorization
|
||||||
|
builder.Services.AddOidcAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddFictionArchiveAuthorization();
|
||||||
|
|
||||||
#region Database
|
#region Database
|
||||||
|
|
||||||
builder.Services.RegisterDbContext<SchedulerServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
|
builder.Services.RegisterDbContext<SchedulerServiceDbContext>(
|
||||||
|
builder.Configuration.GetConnectionString("DefaultConnection"),
|
||||||
|
skipInfrastructure: isSchemaExport);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Event Bus
|
#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
|
#endregion
|
||||||
|
|
||||||
#region Quartz
|
#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 =>
|
opt.UseInMemoryStore();
|
||||||
{
|
|
||||||
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 =>
|
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
|
#endregion
|
||||||
|
|
||||||
var app = builder.Build();
|
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>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<SchedulerServiceDbContext>();
|
||||||
dbContext.UpdateDatabase();
|
dbContext.UpdateDatabase();
|
||||||
}
|
}
|
||||||
@@ -66,7 +92,10 @@ public class Program
|
|||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapGraphQL();
|
app.MapGraphQL();
|
||||||
|
|
||||||
app.RunWithGraphQLCommands(args);
|
app.RunWithGraphQLCommands(args);
|
||||||
|
|||||||
@@ -12,5 +12,15 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=localhost;Database=FictionArchive_SchedulerService;Username=postgres;password=postgres"
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,29 @@ namespace FictionArchive.Service.Shared.Extensions;
|
|||||||
|
|
||||||
public static class DatabaseExtensions
|
public static class DatabaseExtensions
|
||||||
{
|
{
|
||||||
public static IServiceCollection RegisterDbContext<TContext>(this IServiceCollection services,
|
public static IServiceCollection RegisterDbContext<TContext>(
|
||||||
string connectionString) where TContext : FictionArchiveDbContext
|
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;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="GraphQL.Server.Ui.GraphiQL" Version="8.3.3" />
|
<PackageReference Include="GraphQL.Server.Ui.GraphiQL" Version="8.3.3" />
|
||||||
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
|
<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.AspNetCore.CommandLine" Version="15.1.11" />
|
||||||
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
|
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
|
||||||
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
|
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
22
FictionArchive.Service.Shared/SchemaExportDetector.cs
Normal file
22
FictionArchive.Service.Shared/SchemaExportDetector.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,17 @@ using FictionArchive.Service.TranslationService.Models.Enums;
|
|||||||
using FictionArchive.Service.TranslationService.Services;
|
using FictionArchive.Service.TranslationService.Services;
|
||||||
using FictionArchive.Service.TranslationService.Services.Database;
|
using FictionArchive.Service.TranslationService.Services.Database;
|
||||||
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
|
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
|
|
||||||
namespace FictionArchive.Service.TranslationService.GraphQL;
|
namespace FictionArchive.Service.TranslationService.GraphQL;
|
||||||
|
|
||||||
public class Mutation
|
public class Mutation
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
public async Task<TranslationResult> TranslateText(string text, Language from, Language to, string translationEngineKey, TranslationEngineService translationEngineService)
|
public async Task<TranslationResult> TranslateText(string text, Language from, Language to, string translationEngineKey, TranslationEngineService translationEngineService)
|
||||||
{
|
{
|
||||||
var result = await translationEngineService.Translate(from, to, text, translationEngineKey);
|
var result = await translationEngineService.Translate(from, to, text, translationEngineKey);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,19 +2,22 @@ using FictionArchive.Service.TranslationService.Models;
|
|||||||
using FictionArchive.Service.TranslationService.Models.Database;
|
using FictionArchive.Service.TranslationService.Models.Database;
|
||||||
using FictionArchive.Service.TranslationService.Services.Database;
|
using FictionArchive.Service.TranslationService.Services.Database;
|
||||||
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
|
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace FictionArchive.Service.TranslationService.GraphQL;
|
namespace FictionArchive.Service.TranslationService.GraphQL;
|
||||||
|
|
||||||
public class Query
|
public class Query
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[UseFiltering]
|
[UseFiltering]
|
||||||
[UseSorting]
|
[UseSorting]
|
||||||
public IEnumerable<TranslationEngineDescriptor> GetTranslationEngines(IEnumerable<ITranslationEngine> engines)
|
public IEnumerable<TranslationEngineDescriptor> GetTranslationEngines(IEnumerable<ITranslationEngine> engines)
|
||||||
{
|
{
|
||||||
return engines.Select(engine => engine.Descriptor);
|
return engines.Select(engine => engine.Descriptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[UsePaging]
|
[UsePaging]
|
||||||
[UseProjection]
|
[UseProjection]
|
||||||
[UseFiltering]
|
[UseFiltering]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using DeepL;
|
using DeepL;
|
||||||
using FictionArchive.Common.Extensions;
|
using FictionArchive.Common.Extensions;
|
||||||
|
using FictionArchive.Service.Shared;
|
||||||
using FictionArchive.Service.Shared.Extensions;
|
using FictionArchive.Service.Shared.Extensions;
|
||||||
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
||||||
using FictionArchive.Service.Shared.Services.GraphQL;
|
using FictionArchive.Service.Shared.Services.GraphQL;
|
||||||
@@ -18,6 +19,8 @@ public class Program
|
|||||||
{
|
{
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.AddLocalAppsettings();
|
builder.AddLocalAppsettings();
|
||||||
|
|
||||||
@@ -25,24 +28,30 @@ public class Program
|
|||||||
|
|
||||||
#region Event Bus
|
#region Event Bus
|
||||||
|
|
||||||
builder.Services.AddRabbitMQ(opt =>
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
builder.Services.AddRabbitMQ(opt =>
|
||||||
})
|
{
|
||||||
.Subscribe<TranslationRequestCreatedEvent, TranslationRequestCreatedEventHandler>();
|
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
||||||
|
})
|
||||||
|
.Subscribe<TranslationRequestCreatedEvent, TranslationRequestCreatedEventHandler>();
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
#region Database
|
#region Database
|
||||||
|
|
||||||
builder.Services.RegisterDbContext<TranslationServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
|
builder.Services.RegisterDbContext<TranslationServiceDbContext>(
|
||||||
|
builder.Configuration.GetConnectionString("DefaultConnection"),
|
||||||
|
skipInfrastructure: isSchemaExport);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region GraphQL
|
#region GraphQL
|
||||||
|
|
||||||
builder.Services.AddDefaultGraphQl<Query, Mutation>();
|
builder.Services.AddDefaultGraphQl<Query, Mutation>()
|
||||||
|
.AddAuthorization();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -55,14 +64,19 @@ public class Program
|
|||||||
builder.Services.AddTransient<ITranslationEngine, DeepLTranslationEngine>();
|
builder.Services.AddTransient<ITranslationEngine, DeepLTranslationEngine>();
|
||||||
|
|
||||||
builder.Services.AddTransient<TranslationEngineService>();
|
builder.Services.AddTransient<TranslationEngineService>();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
// Authentication & Authorization
|
||||||
|
builder.Services.AddOidcAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddFictionArchiveAuthorization();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Update database
|
// Update database (skip in schema export mode)
|
||||||
using (var scope = app.Services.CreateScope())
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<TranslationServiceDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<TranslationServiceDbContext>();
|
||||||
dbContext.UpdateDatabase();
|
dbContext.UpdateDatabase();
|
||||||
}
|
}
|
||||||
@@ -70,7 +84,10 @@ public class Program
|
|||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapGraphQL();
|
app.MapGraphQL();
|
||||||
|
|
||||||
app.RunWithGraphQLCommands(args);
|
app.RunWithGraphQLCommands(args);
|
||||||
|
|||||||
@@ -15,5 +15,15 @@
|
|||||||
"ConnectionString": "amqp://localhost",
|
"ConnectionString": "amqp://localhost",
|
||||||
"ClientIdentifier": "TranslationService"
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
using FictionArchive.Service.Shared.Constants;
|
||||||
using FictionArchive.Service.UserService.Models.Database;
|
using FictionArchive.Service.UserService.Models.Database;
|
||||||
using FictionArchive.Service.UserService.Services;
|
using FictionArchive.Service.UserService.Services;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
|
|
||||||
namespace FictionArchive.Service.UserService.GraphQL;
|
namespace FictionArchive.Service.UserService.GraphQL;
|
||||||
|
|
||||||
public class Mutation
|
public class Mutation
|
||||||
{
|
{
|
||||||
|
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
|
||||||
public async Task<User> RegisterUser(string username, string email, string oAuthProviderId,
|
public async Task<User> RegisterUser(string username, string email, string oAuthProviderId,
|
||||||
string? inviterOAuthProviderId, UserManagementService userManagementService)
|
string? inviterOAuthProviderId, UserManagementService userManagementService)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
using FictionArchive.Service.UserService.Models.Database;
|
using FictionArchive.Service.UserService.Models.Database;
|
||||||
using FictionArchive.Service.UserService.Services;
|
using FictionArchive.Service.UserService.Services;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
|
|
||||||
namespace FictionArchive.Service.UserService.GraphQL;
|
namespace FictionArchive.Service.UserService.GraphQL;
|
||||||
|
|
||||||
public class Query
|
public class Query
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
public async Task<IQueryable<User>> GetUsers(UserManagementService userManagementService)
|
public async Task<IQueryable<User>> GetUsers(UserManagementService userManagementService)
|
||||||
{
|
{
|
||||||
return userManagementService.GetUsers();
|
return userManagementService.GetUsers();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using FictionArchive.Common.Extensions;
|
||||||
|
using FictionArchive.Service.Shared;
|
||||||
using FictionArchive.Service.Shared.Extensions;
|
using FictionArchive.Service.Shared.Extensions;
|
||||||
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
||||||
using FictionArchive.Service.UserService.GraphQL;
|
using FictionArchive.Service.UserService.GraphQL;
|
||||||
@@ -11,40 +13,57 @@ public class Program
|
|||||||
{
|
{
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
builder.AddLocalAppsettings();
|
||||||
|
|
||||||
#region Event Bus
|
#region Event Bus
|
||||||
|
|
||||||
builder.Services.AddRabbitMQ(opt =>
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
builder.Services.AddRabbitMQ(opt =>
|
||||||
})
|
{
|
||||||
.Subscribe<AuthUserAddedEvent, AuthUserAddedEventHandler>();
|
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
||||||
|
})
|
||||||
|
.Subscribe<AuthUserAddedEvent, AuthUserAddedEventHandler>();
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region GraphQL
|
#region GraphQL
|
||||||
|
|
||||||
builder.Services.AddDefaultGraphQl<Query, Mutation>();
|
builder.Services.AddDefaultGraphQl<Query, Mutation>()
|
||||||
|
.AddAuthorization();
|
||||||
|
|
||||||
#endregion
|
#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.AddTransient<UserManagementService>();
|
||||||
|
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
|
|
||||||
|
// Authentication & Authorization
|
||||||
|
builder.Services.AddOidcAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddFictionArchiveAuthorization();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Update database
|
// Update database (skip in schema export mode)
|
||||||
using (var scope = app.Services.CreateScope())
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<UserServiceDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<UserServiceDbContext>();
|
||||||
dbContext.UpdateDatabase();
|
dbContext.UpdateDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapGraphQL();
|
app.MapGraphQL();
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
app.RunWithGraphQLCommands(args);
|
app.RunWithGraphQLCommands(args);
|
||||||
|
|||||||
@@ -12,5 +12,15 @@
|
|||||||
"ConnectionString": "amqp://localhost",
|
"ConnectionString": "amqp://localhost",
|
||||||
"ClientIdentifier": "UserService"
|
"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
10
README.md
Normal 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
202
docker-compose.yml
Normal 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:
|
||||||
38
fictionarchive-web-astro/.dockerignore
Normal file
38
fictionarchive-web-astro/.dockerignore
Normal 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
|
||||||
12
fictionarchive-web-astro/.env.example
Normal file
12
fictionarchive-web-astro/.env.example
Normal 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
24
fictionarchive-web-astro/.gitignore
vendored
Normal 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/
|
||||||
4
fictionarchive-web-astro/.vscode/extensions.json
vendored
Normal file
4
fictionarchive-web-astro/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
11
fictionarchive-web-astro/.vscode/launch.json
vendored
Normal file
11
fictionarchive-web-astro/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
45
fictionarchive-web-astro/Dockerfile
Normal file
45
fictionarchive-web-astro/Dockerfile
Normal 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"]
|
||||||
43
fictionarchive-web-astro/README.md
Normal file
43
fictionarchive-web-astro/README.md
Normal 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).
|
||||||
17
fictionarchive-web-astro/astro.config.mjs
Normal file
17
fictionarchive-web-astro/astro.config.mjs
Normal 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()],
|
||||||
|
},
|
||||||
|
});
|
||||||
28
fictionarchive-web-astro/codegen.ts
Normal file
28
fictionarchive-web-astro/codegen.ts
Normal 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;
|
||||||
16
fictionarchive-web-astro/components.json
Normal file
16
fictionarchive-web-astro/components.json
Normal 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"
|
||||||
|
}
|
||||||
35
fictionarchive-web-astro/eslint.config.js
Normal file
35
fictionarchive-web-astro/eslint.config.js
Normal 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
12253
fictionarchive-web-astro/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
fictionarchive-web-astro/package.json
Normal file
51
fictionarchive-web-astro/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
fictionarchive-web-astro/public/favicon.svg
Normal file
9
fictionarchive-web-astro/public/favicon.svg
Normal 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 |
29
fictionarchive-web-astro/src/layouts/AppLayout.astro
Normal file
29
fictionarchive-web-astro/src/layouts/AppLayout.astro
Normal 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>
|
||||||
115
fictionarchive-web-astro/src/lib/auth/authStore.ts
Normal file
115
fictionarchive-web-astro/src/lib/auth/authStore.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import { UserManager, WebStorageStateStore, type UserManagerSettings } from 'oidc-client-ts'
|
import { UserManager, WebStorageStateStore, type UserManagerSettings } from 'oidc-client-ts';
|
||||||
|
|
||||||
const authority = import.meta.env.VITE_OIDC_AUTHORITY
|
const authority = import.meta.env.PUBLIC_OIDC_AUTHORITY;
|
||||||
const clientId = import.meta.env.VITE_OIDC_CLIENT_ID
|
const clientId = import.meta.env.PUBLIC_OIDC_CLIENT_ID;
|
||||||
const redirectUri = import.meta.env.VITE_OIDC_REDIRECT_URI
|
const redirectUri = import.meta.env.PUBLIC_OIDC_REDIRECT_URI;
|
||||||
const postLogoutRedirectUri =
|
const postLogoutRedirectUri = import.meta.env.PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI ?? redirectUri;
|
||||||
import.meta.env.VITE_OIDC_POST_LOGOUT_REDIRECT_URI ?? redirectUri
|
const scope = import.meta.env.PUBLIC_OIDC_SCOPE ?? 'openid profile email';
|
||||||
const scope = import.meta.env.VITE_OIDC_SCOPE ?? 'openid profile email'
|
|
||||||
|
|
||||||
export const isOidcConfigured =
|
export const isOidcConfigured =
|
||||||
Boolean(authority) && Boolean(clientId) && Boolean(redirectUri)
|
Boolean(authority) && Boolean(clientId) && Boolean(redirectUri);
|
||||||
|
|
||||||
function buildSettings(): UserManagerSettings | null {
|
function buildSettings(): UserManagerSettings | null {
|
||||||
if (!isOidcConfigured) return null
|
if (!isOidcConfigured) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authority: authority!,
|
authority: authority!,
|
||||||
@@ -26,11 +25,11 @@ function buildSettings(): UserManagerSettings | null {
|
|||||||
typeof window !== 'undefined'
|
typeof window !== 'undefined'
|
||||||
? new WebStorageStateStore({ store: window.localStorage })
|
? new WebStorageStateStore({ store: window.localStorage })
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userManager = (() => {
|
export const userManager = (() => {
|
||||||
const settings = buildSettings()
|
const settings = buildSettings();
|
||||||
if (!settings) return null
|
if (!settings) return null;
|
||||||
return new UserManager(settings)
|
return new UserManager(settings);
|
||||||
})()
|
})();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { initAuth } from '$lib/auth/authStore';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initAuth();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
38
fictionarchive-web-astro/src/lib/components/HomePage.svelte
Normal file
38
fictionarchive-web-astro/src/lib/components/HomePage.svelte
Normal 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>
|
||||||
33
fictionarchive-web-astro/src/lib/components/Navbar.svelte
Normal file
33
fictionarchive-web-astro/src/lib/components/Navbar.svelte
Normal 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>
|
||||||
@@ -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}
|
||||||
119
fictionarchive-web-astro/src/lib/components/NovelCard.svelte
Normal file
119
fictionarchive-web-astro/src/lib/components/NovelCard.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
117
fictionarchive-web-astro/src/lib/components/NovelsPage.svelte
Normal file
117
fictionarchive-web-astro/src/lib/components/NovelsPage.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as Badge } from "./badge.svelte";
|
||||||
|
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
25
fictionarchive-web-astro/src/lib/components/ui/card/index.ts
Normal file
25
fictionarchive-web-astro/src/lib/components/ui/card/index.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./input.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Input,
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
785
fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts
generated
Normal file
785
fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts
generated
Normal 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>;
|
||||||
21
fictionarchive-web-astro/src/lib/graphql/client.ts
Normal file
21
fictionarchive-web-astro/src/lib/graphql/client.ts
Normal 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();
|
||||||
@@ -21,6 +21,17 @@ query Novels($first: Int, $after: String) {
|
|||||||
originalPath
|
originalPath
|
||||||
newPath
|
newPath
|
||||||
}
|
}
|
||||||
|
rawStatus
|
||||||
|
lastUpdatedTime
|
||||||
|
chapters {
|
||||||
|
order
|
||||||
|
name {
|
||||||
|
texts {
|
||||||
|
language
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pageInfo {
|
pageInfo {
|
||||||
49
fictionarchive-web-astro/src/lib/graphql/urqlStore.ts
Normal file
49
fictionarchive-web-astro/src/lib/graphql/urqlStore.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
13
fictionarchive-web-astro/src/lib/utils.ts
Normal file
13
fictionarchive-web-astro/src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||||
|
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||||
|
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user